diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000000..bbe2ee8e93 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,8 @@ +comment: false # Ref: https://docs.codecov.io/docs/pull-request-comments + +coverage: + status: + project: + default: + threshold: 0% # Ref: https://docs.codecov.io/docs/codecovyml-reference#coveragestatus + target: auto diff --git a/.eslintignore b/.eslintignore index 2f59b98bca..46bb1ad2b2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,8 +1,11 @@ **/node_modules/** **/dist/** +**/dist-types/** **/storybook-static/** **/coverage/** **/build/** **/.git/** **/public/** **/microsite/** +**/templates/** +**/sample-templates/** diff --git a/.github/ISSUE_TEMPLATE/feature_template.md b/.github/ISSUE_TEMPLATE/feature_template.md index 012b8d7a06..d70622bf52 100644 --- a/.github/ISSUE_TEMPLATE/feature_template.md +++ b/.github/ISSUE_TEMPLATE/feature_template.md @@ -1,7 +1,7 @@ --- name: 'Feature Request' about: 'Suggest new features and changes' -labels: help wanted +labels: enhancement --- diff --git a/.github/ISSUE_TEMPLATE/rfc_template.md b/.github/ISSUE_TEMPLATE/rfc_template.md new file mode 100644 index 0000000000..c4990ee5d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/rfc_template.md @@ -0,0 +1,26 @@ +--- +name: 'RFC' +about: 'Request For Comments (RFC) from the community' +labels: rfc +title: '[RFC] ' +--- + +**Status:** Open for comments + + + +## Need + + + +## Proposal + + + +## Alternatives + + + +## Risks + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 57746da2e8..0437698301 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +4,9 @@ That makes it easier to understand the change so we can :shipit: faster. --> #### :heavy_check_mark: Checklist + + - [ ] All tests are passing `yarn test` - [ ] Screenshots attached (for UI changes) - [ ] Relevant documentation updated diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..59847fd65f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: '/' + schedule: + interval: daily + time: '04:00' + open-pull-requests-limit: 5 + labels: + - dependencies + - package-ecosystem: npm + directory: '/microsite/' + schedule: + interval: daily + time: '04:00' + open-pull-requests-limit: 2 + labels: + - dependencies diff --git a/.github/workflows/chromatic-storybook-test.yml b/.github/workflows/chromatic-storybook-test.yml index e38b494a73..0bcaf400d0 100644 --- a/.github/workflows/chromatic-storybook-test.yml +++ b/.github/workflows/chromatic-storybook-test.yml @@ -14,7 +14,37 @@ jobs: - uses: actions/checkout@v2 with: fetch-depth: 0 # Required to retrieve git history - - run: yarn install && yarn build-storybook + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + run: yarn install --frozen-lockfile + # End of yarn setup + + - run: yarn build-storybook + - uses: chromaui/action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/frontend.yml b/.github/workflows/ci.yml similarity index 51% rename from .github/workflows/frontend.yml rename to .github/workflows/ci.yml index dc65b987a8..c8c92c57ff 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,11 @@ -name: Frontend CI +name: CI on: pull_request: paths-ignore: - 'microsite/**' jobs: - build: + verify: runs-on: ubuntu-latest strategy: @@ -20,47 +20,67 @@ jobs: - uses: actions/checkout@v2 - name: fetch branch master run: git fetch origin master - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: cache node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} + + # Beginning of yarn setup, keep in sync between all workflows. + # TODO(Rugvip): move this to composite action once all features we use are supported - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + + # Cache every node_modules folder inside the monorepo + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + # We use both yarn.lock and package.json as cache keys to ensure that + # changes to local monorepo packages bust the cache. + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + + # If we get a cache hit for node_modules, there's no need to bring in the global + # yarn cache or run yarn install, as all dependencies will be installed already. + + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: yarn install + if: steps.cache-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + # End of yarn setup - name: check for yarn.lock changes id: yarn-lock run: git diff --quiet origin/master HEAD -- yarn.lock continue-on-error: true - - name: yarn install - run: yarn install --frozen-lockfile - - name: verify doc links run: node docs/verify-links.js + - name: prettier + run: yarn prettier:check + - name: lint run: yarn lerna -- run lint --since origin/master - name: type checking and declarations - run: yarn tsc --incremental false + run: yarn tsc:full - name: build changed packages if: ${{ steps.yarn-lock.outcome == 'success' }} - # Need to build all dependencies as well to be able to run tests later - run: yarn lerna -- run build --since origin/master --include-dependencies + run: yarn lerna -- run build --since origin/master - name: build all packages if: ${{ steps.yarn-lock.outcome == 'failure' }} @@ -73,12 +93,24 @@ jobs: if: ${{ steps.yarn-lock.outcome == 'success' }} run: yarn lerna -- run test --since origin/master -- --coverage - - name: test all packages + - name: test all packages (and upload coverage) if: ${{ steps.yarn-lock.outcome == 'failure' }} - run: yarn lerna -- run test -- --coverage + run: | + yarn lerna -- run test -- --coverage + bash <(curl -s https://codecov.io/bash) - name: verify plugin template run: yarn lerna -- run diff -- --check - - name: verify storybook - run: yarn workspace storybook build-storybook + - name: ensure clean working directory + run: | + if files=$(git ls-files --exclude-standard --others --modified) && [[ -z "$files" ]]; then + exit 0 + else + echo "" + echo "Working directory has been modified:" + echo "" + git status --short + echo "" + exit 1 + fi diff --git a/.github/workflows/cli-win.yml b/.github/workflows/e2e-win.yml similarity index 55% rename from .github/workflows/cli-win.yml rename to .github/workflows/e2e-win.yml index 4e7a186282..5bab1b1299 100644 --- a/.github/workflows/cli-win.yml +++ b/.github/workflows/e2e-win.yml @@ -1,12 +1,13 @@ -name: CLI Test Windows +name: E2E Test Windows -# Building on windows is really slow, so this workflow is separate from cli.yml and only builds on changes +# Building on windows is really slow, so this workflow is separate from e2e.yml and only builds on changes # to the cli itself. They're more likely to introduce issues on windows, compared to changes to core and yarn.lock. on: pull_request: paths: - - '.github/workflows/cli-win.yml' + - '.github/workflows/e2e-win.yml' - 'packages/cli/**' + - 'packages/e2e/**' - 'packages/create-app/**' jobs: @@ -25,23 +26,16 @@ jobs: name: Node ${{ matrix.node-version }} on ${{ matrix.os }} steps: - uses: actions/checkout@v2 - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - name: yarn install run: yarn install --frozen-lockfile + - run: yarn tsc - - run: yarn build - - name: verify app and plugin creation - run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js + - name: yarn build + run: yarn build --ignore example-app --ignore example-backend --ignore @techdocs/cli + - name: run E2E test + run: yarn workspace e2e-test start diff --git a/.github/workflows/cli.yml b/.github/workflows/e2e.yml similarity index 66% rename from .github/workflows/cli.yml rename to .github/workflows/e2e.yml index 158310b5ea..d53ca1b607 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/e2e.yml @@ -1,11 +1,10 @@ -name: CLI Test +name: E2E Test Linux on: pull_request: paths-ignore: - 'microsite/**' - jobs: build: runs-on: ${{ matrix.os }} @@ -34,30 +33,44 @@ jobs: name: Node ${{ matrix.node-version }} on ${{ matrix.os }} steps: - uses: actions/checkout@v2 - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - name: yarn install run: yarn install --frozen-lockfile + # End of yarn setup + - run: yarn tsc - - run: yarn build - - name: verify app and plugin creation + - name: yarn build + run: yarn build --ignore example-app --ignore example-backend --ignore @techdocs/cli + - name: run E2E test + run: | + sudo sysctl fs.inotify.max_user_watches=524288 + yarn workspace e2e-test start env: POSTGRES_HOST: localhost POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - run: | - sudo sysctl fs.inotify.max_user_watches=524288 - node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml new file mode 100644 index 0000000000..3f65cf9aec --- /dev/null +++ b/.github/workflows/master-win.yml @@ -0,0 +1,60 @@ +name: Master Build Windows + +on: + push: + branches: [master] + +jobs: + build: + runs-on: windows-latest + + strategy: + matrix: + node-version: [12.x] + + env: + CI: true + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - uses: actions/checkout@v2 + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + - name: find location of global yarn cache + id: yarn-cache + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + run: yarn install --frozen-lockfile + # End of yarn setup + + - name: lint + run: yarn lerna -- run lint + + - name: type checking and declarations + run: yarn tsc:full + + - name: verify type dependencies + run: yarn lint:type-deps + + - name: test + run: yarn lerna -- run test + + - name: Discord notification + if: ${{ failure() }} + uses: Ilshidur/action-discord@0.2.0 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + with: + args: 'Windows master build failed https://github.com/{{GITHUB_REPOSITORY}}/actions/runs/{{GITHUB_RUN_ID}}' diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 3ef0c1fee3..f99c266241 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -1,4 +1,4 @@ -name: Master Build +name: Main Master Build on: push: @@ -18,35 +18,40 @@ jobs: steps: - uses: actions/checkout@v2 - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: cache node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - name: yarn install run: yarn install --frozen-lockfile + # End of yarn setup - name: lint run: yarn lerna -- run lint - name: type checking and declarations - run: yarn tsc --incremental false + run: yarn tsc:full - name: build run: yarn build @@ -54,8 +59,10 @@ jobs: - name: verify type dependencies run: yarn lint:type-deps - - name: test - run: yarn lerna -- run test -- --coverage + - name: test (and upload coverage) + run: | + yarn lerna -- run test -- --coverage + bash <(curl -s https://codecov.io/bash) # Publishes current version of packages that are not already present in the registry - name: publish @@ -66,6 +73,14 @@ jobs: # Tags the commit with the version in the core package if the tag doesn't exist - uses: Klemensas/action-autotag@1.2.3 with: - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - package_root: "packages/core" - tag_prefix: "v" + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + package_root: 'packages/core' + tag_prefix: 'v' + + - name: Discord notification + if: ${{ failure() }} + uses: Ilshidur/action-discord@0.2.0 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + with: + args: 'Master build failed https://github.com/{{GITHUB_REPOSITORY}}/actions/runs/{{GITHUB_RUN_ID}}' diff --git a/.github/workflows/microsite-build-check.yml b/.github/workflows/microsite-build-check.yml index 41f43399ca..ff290b71e4 100644 --- a/.github/workflows/microsite-build-check.yml +++ b/.github/workflows/microsite-build-check.yml @@ -21,32 +21,18 @@ jobs: steps: - uses: actions/checkout@v2 - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: cache node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - registry-url: https://registry.npmjs.org/ # Needed for auth + # Skip caching of microsite dependencies, it keeps the global cache size + # smaller, which make Windows builds a lot faster for the rest of the project. - name: yarn install run: yarn install --frozen-lockfile + working-directory: microsite - name: build microsite - run: yarn workspace backstage-microsite build + run: yarn build + working-directory: microsite diff --git a/.github/workflows/microsite-with-storybook-deploy.yml b/.github/workflows/microsite-with-storybook-deploy.yml index a4828ba55f..22fd48789a 100644 --- a/.github/workflows/microsite-with-storybook-deploy.yml +++ b/.github/workflows/microsite-with-storybook-deploy.yml @@ -9,6 +9,7 @@ on: - 'packages/storybook/**' - 'packages/core/src/**' - 'microsite/**' + - 'docs/**' jobs: deploy-microsite-and-storybook: @@ -24,23 +25,6 @@ jobs: steps: - uses: actions/checkout@v2 - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: cache node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 @@ -48,11 +32,18 @@ jobs: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - - name: yarn install + # We avoid caching in this workflow, as we're running an install of both the top-level + # dependencies and the microsite. We leave it to the main master workflow to produce the + # cache, as that results in a smaller bundle. + - name: top-level yarn install run: yarn install --frozen-lockfile + - name: microsite yarn install + run: yarn install --frozen-lockfile + working-directory: microsite - name: build microsite - run: yarn workspace backstage-microsite build + run: yarn build + working-directory: microsite - name: build storybook run: yarn workspace storybook build-storybook diff --git a/.github/workflows/techdocs-project-board.yml b/.github/workflows/techdocs-project-board.yml new file mode 100644 index 0000000000..b389cf6bf5 --- /dev/null +++ b/.github/workflows/techdocs-project-board.yml @@ -0,0 +1,53 @@ +name: Automatically add new TechDocs Issues and PRs to the GitHub project board +# Development of TechDocs in Backstage is managed by this Kanban board - https://github.com/spotify/backstage/projects/5 +# New issues with TechDocs in their title or docs-like-code label will be added to the board. + +on: + issues: + types: [opened, reopened, labeled, edited] + pull_request: + types: [opened, reopened, labeled, edited] + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + assign_issue_or_pr_to_project: + runs-on: ubuntu-latest + name: Triage + steps: + - name: Assign new issue to Incoming based on its title. + uses: srggrs/assign-one-project-github-action@1.2.0 + if: | + contains(github.event.issue.title, 'TechDocs') || + contains(github.event.issue.title, 'techdocs') || + contains(github.event.issue.title, 'Techdocs') + with: + project: 'https://github.com/spotify/backstage/projects/5' + column_name: 'Incoming' + + - name: Assign new issue to Incoming based on its label. + uses: srggrs/assign-one-project-github-action@1.2.0 + if: | + contains(github.event.issue.labels.*.name, 'docs-like-code') + with: + project: 'https://github.com/spotify/backstage/projects/5' + column_name: 'Incoming' + + - name: Assign new PR to Incoming based on its title. + uses: srggrs/assign-one-project-github-action@1.2.0 + if: | + contains(github.event.pull_request.title, 'TechDocs') || + contains(github.event.pull_request.title, 'techdocs') || + contains(github.event.pull_request.title, 'Techdocs') + with: + project: 'https://github.com/spotify/backstage/projects/5' + column_name: 'Incoming' + + - name: Assign new PR to Incoming based on its label. + uses: srggrs/assign-one-project-github-action@1.2.0 + if: | + contains(github.event.pull_request.labels.*.name, 'docs-like-code') + with: + project: 'https://github.com/spotify/backstage/projects/5' + column_name: 'Incoming' diff --git a/.gitignore b/.gitignore index 743561c3b6..e7d294d55a 100644 --- a/.gitignore +++ b/.gitignore @@ -89,9 +89,7 @@ typings/ # Nuxt.js build / generate output .nuxt dist - -# Microsite build output -microsite/build +dist-types # Gatsby files .cache/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..9e75b74eee --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +.yarn +dist +microsite/build +coverage +*.hbs +templates +plugins/scaffolder-backend/sample-templates +.vscode diff --git a/ADOPTERS.md b/ADOPTERS.md index 7a96198529..0ba3e3196b 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,13 +1,15 @@ -| Organization | Contact | Description of Use | -| --------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | -| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | -| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | -| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | -| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | -| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | -| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | -| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | -| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | -| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | +| Organization | Contact | Description of Use | +| -------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. | +| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | +| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | +| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | +| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | +| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | +| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | +| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | +| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | +| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | +| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f03e3fa5..294c57815e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,74 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re ## Next Release +### @backstage/core + +- Introduced initial version of an inverted app/plugin relationship, where plugins export components for apps to use, instead registering themselves directly into the app. This enables more fine-grained control of plugin features, and also composition of plugins such as catalog pages with additional cards and tabs. This breaks the use of `RouteRef`s, and there will be more changes related to this in the future, but this change lays the initial foundation. See `packages/app` and followup PRs for how to update plugins for this change. [#2076](https://github.com/spotify/backstage/pull/2076) +- Switch to an automatic dependency injection mechanism for all Utility APIs, allowing plugins to ship default implementations of their APIs. See [https://backstage.io/docs/api/utility-apis](https://backstage.io/docs/api/utility-apis). [#2285](https://github.com/spotify/backstage/pull/2285) + > Collect changes for the next release below +### @backstage/cli + +- Change `backstage-cli backend:build-image` to forward all args to `docker image build`, instead of just tag. Also add `--build` flag for building all dependent packages before packaging the workspace for the docker build. [#2299](https://github.com/spotify/backstage/pull/2299) + ### @backstage/create-app +- Change root `tsc` output dir to `dist-types`, in order to allow for standalone plugin repos. [#2278](https://github.com/spotify/backstage/pull/2278) + +## v0.1.1-alpha.21 + +- Added many more frontend plugins to the template along with the sidebar. [#1942](https://github.com/spotify/backstage/pull/1942), [#2084](https://github.com/spotify/backstage/pull/2084) + +### @backstage/core + +- Material-UI: Bumped to 4.11.0, which is the version that create-app will + resolve to, because we wanted to get the renaming of ExpansionPanel to + Accordion into place. This gets rid of a lot of console deprecation warnings + in newly scaffolded apps. + +### @backstage/cli + +- Set `NODE_ENV` to `test` when running test. [#2214](https://github.com/spotify/backstage/pull/2214) + +- Fix for backend plugins names requiring to be prefixed with `@backstage` to build. [#2224](https://github.com/spotify/backstage/pull/2224) + +### @backstage/backend-common + +- The backend plugin + [service builder](https://github.com/spotify/backstage/blob/master/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts) + no longer adds `express.json()` automatically to all routes. While convenient + in a lot of cases, it also led to problems where for example the proxy + middleware could hang because the body had already been altered and could not + be streamed. Also, plugins that rather wanted to handle e.g. form encoded data + still had to cater to that manually. We therefore decided to let plugins add + `express.json()` themselves if they happen to deal with JSON data. + +### @backstage/catalog-backend + +- Add rules configuration for catalog location and entity kinds. The default rules should cover most use-cases, but you may need to allow specific entity kinds when using things like Template or Group entities. [#2118](https://github.com/spotify/backstage/pull/2118) + +## v0.1.1-alpha.20 + +### @backstage/cli + +- Use config files according to `NODE_ENV` when serving and building frontend packages. [#2077](https://github.com/spotify/backstage/pull/2077) + +- Pin `rollup-plugin-dts` to avoid a later broken version. [#2097](https://github.com/spotify/backstage/pull/2097) + +## v0.1.1-alpha.19 + +### @backstage/backend-common + +- Allow listen host and port to be configured separately, in order to support PORT environment variables. [#1950](https://github.com/spotify/backstage/pull/1950) + +### @backstage/core + +- Added new `DiscoveryApi` for discovering backend endpoint in the frontend, and use in most plugins. See [packages/app/src/apis.ts](https://github.com/spotify/backstage/blob/master/packages/app/src/apis.ts) for how to register in your app. [#2074](https://github.com/spotify/backstage/pull/2074) + +### @backstage/create-app + +- Added catalog and scaffolder frontend plugins to the template along with the sidebar. [#1942](https://github.com/spotify/backstage/pull/1942), [#2084](https://github.com/spotify/backstage/pull/2084) - Many plugins have been added to the catalog and will for now be required to be added to separate apps as well. This will be solved as [#1536](https://github.com/spotify/backstage/issues/1536) gets sorted out, but for now you may need to install some plugins just to get pages to work. ### @backstage/catalog-backend diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 69f6457dd9..55269dd2a5 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -4,12 +4,12 @@ This code of conduct outlines our expectations for participants within the **Spo Our open source community strives to: -* **Be friendly and patient.** -* **Be welcoming**: We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability. -* **Be considerate**: Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language. -* **Be respectful**: Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. -* **Be careful in the words that we choose**: we are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. -* **Try to understand why we disagree**: Disagreements, both social and technical, happen all the time. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. The strength of our community comes from its diversity, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes. +- **Be friendly and patient.** +- **Be welcoming**: We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability. +- **Be considerate**: Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language. +- **Be respectful**: Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. +- **Be careful in the words that we choose**: we are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. +- **Try to understand why we disagree**: Disagreements, both social and technical, happen all the time. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. The strength of our community comes from its diversity, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes. ## Definitions @@ -18,7 +18,7 @@ Harassment includes, but is not limited to: - Offensive comments related to gender, gender identity and expression, sexual orientation, disability, mental illness, neuro(a)typicality, physical appearance, body size, race, age, regional discrimination, political or religious affiliation - Unwelcome comments regarding a person’s lifestyle choices and practices, including those related to food, health, parenting, drugs, and employment - Deliberate misgendering. This includes deadnaming or persistently using a pronoun that does not correctly reflect a person's gender identity. You must address people by the name they give you when not addressing them by their username or handle -- Physical contact and simulated physical contact (eg, textual descriptions like “*hug*” or “*backrub*”) without consent or after a request to stop +- Physical contact and simulated physical contact (eg, textual descriptions like “_hug_” or “_backrub_”) without consent or after a request to stop - Threats of violence, both physical and psychological - Incitement of violence towards any individual, including encouraging a person to commit suicide or to engage in self-harm - Deliberate intimidation @@ -39,7 +39,6 @@ Our open source community prioritizes marginalized people’s safety over privil - Communicating in a ‘tone’ you don’t find congenial - Criticizing racist, sexist, cissexist, or otherwise oppressive behavior or assumptions - ### Diversity Statement We encourage everyone to participate and are committed to building a community for all. Although we will fail at times, we seek to treat everyone both as fairly and equally as possible. Whenever a participant has made a mistake, we expect them to take responsibility for it. If someone has been harmed or offended, it is our responsibility to listen carefully and respectfully, and do our best to right the wrong. @@ -53,18 +52,18 @@ If you experience or witness unacceptable behavior—or have any other concerns - Your contact information. - Names (real, nicknames, or pseudonyms) of any individuals involved. If there are additional witnesses, please -include them as well. Your account of what occurred, and if you believe the incident is ongoing. If there is a publicly available record (e.g. a mailing list archive or a public IRC logger), please include a link. + include them as well. Your account of what occurred, and if you believe the incident is ongoing. If there is a publicly available record (e.g. a mailing list archive or a public IRC logger), please include a link. - Any additional information that may be helpful. After filing a report, a representative will contact you personally, review the incident, follow up with any additional questions, and make a decision as to how to respond. If the person who is harassing you is part of the response team, they will recuse themselves from handling your incident. If the complaint originates from a member of the response team, it will be handled by a different member of the response team. We will respect confidentiality requests for the purpose of protecting victims of abuse. ### Attribution & Acknowledgements -We all stand on the shoulders of giants across many open source communities. We'd like to thank the communities and projects that established code of conducts and diversity statements as our inspiration: +We all stand on the shoulders of giants across many open source communities. We'd like to thank the communities and projects that established code of conducts and diversity statements as our inspiration: -* [Django](https://www.djangoproject.com/conduct/reporting/) -* [Python](https://www.python.org/community/diversity/) -* [Ubuntu](http://www.ubuntu.com/about/about-ubuntu/conduct) -* [Contributor Covenant](http://contributor-covenant.org/) -* [Geek Feminism](http://geekfeminism.org/about/code-of-conduct/) -* [Citizen Code of Conduct](http://citizencodeofconduct.org/) +- [Django](https://www.djangoproject.com/conduct/reporting/) +- [Python](https://www.python.org/community/diversity/) +- [Ubuntu](http://www.ubuntu.com/about/about-ubuntu/conduct) +- [Contributor Covenant](http://contributor-covenant.org/) +- [Geek Feminism](http://geekfeminism.org/about/code-of-conduct/) +- [Citizen Code of Conduct](http://citizencodeofconduct.org/) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b8febd1d2..0d6a8db50c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,4 @@ ---- -id: CONTRIBUTING -title: Contributing ---- +# Contributing to Backstage Our vision for Backstage is for it to become the trusted standard toolbox (read: UX layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. @@ -31,7 +28,7 @@ What kind of plugins should/could be created? Some inspiration from the 120+ plu ## Suggesting a plugin -If you start developing a plugin that you aim to release as open source, we suggest that you create a new [new Issue](https://github.com/spotify/backstage/issues/new?template=plugin_template.md). This helps the community know what plugins are in development. +If you start developing a plugin that you aim to release as open source, we suggest that you create a new [new Issue](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development. You can also use this process if you have an idea for a good plugin but you hope that someone else will pick up the work. @@ -65,35 +62,7 @@ Have you started using Backstage? Adding your company to [ADOPTERS](ADOPTERS.md) So...feel ready to jump in? Let's do this. 👏🏻💯 -To run a Backstage app, you will need to have the following installed: - -- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) -- [NodeJS](https://nodejs.org/en/download/) - Active LTS Release, currently v12 -- [yarn](https://classic.yarnpkg.com/en/docs/install) - -After cloning this repo, open a terminal window and start the example app using the following commands from the project root: - -```bash -yarn install # Install dependencies - -yarn start # Start dev server, use --check to enable linting and type-checks -``` - -The final `yarn start` command should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. - -Depending on the work you're doing, you often also want to run the example backend. Start the backend in a separate terminal session using the following: - -```bash -cd packages/backend - -yarn start - -yarn lerna run mock-data # Populate the backend with mock data -``` - -And that's it! You are good to go 👍 - -If you need help, just jump into our [Discord chatroom](https://discord.gg/MUpMjP2). +Start by reading our [Getting Started](https://backstage.io/docs/getting-started/) page. If you need help, just jump into our [Discord chatroom](https://discord.gg/MUpMjP2). # Coding Guidelines diff --git a/Dockerfile b/Dockerfile index fa89debcee..174548a90c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,14 @@ FROM nginx:mainline +# The purpose of this image is to serve the frontend app content separately. +# By default the Backstage backend uses the app-backend plugin to serve the +# app from the backend itself, but it may be desirable to move the frontend +# content serving to a separate deployment, in which case this image can be used. + # This dockerfile requires the app to be built on the host first, as it # simply copies in the build output into the image. -# The safest way to build this image is to use `yarn docker-build` +# The safest way to build this image is to use `yarn docker-build:app` RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* diff --git a/OWNERS.md b/OWNERS.md new file mode 100644 index 0000000000..1e7fe5b774 --- /dev/null +++ b/OWNERS.md @@ -0,0 +1,19 @@ +# Owners + +- See [CONTRIBUTING.md](CONTRIBUTING.md) for general contribution guidelines. + +## Maintainers 🏓 + +- Patrik Oldsberg (@Rugvip, Spotify) +- Fredrik Adelöw (@freben, Spotify) +- Raghunandan Balachandran (@soapraj, Spotify) +- Ben Lambert (@benjdlambert, Spotify) +- Marcus Eide (@marcuseide, Spotify) +- Niklas Ek (@nikek, Spotify) +- Stefan Ålund (@stefanalund, Spotify) +- Kat Zhou (@katz95, Spotify) + +## Hall of Fame 👏 + +- Andrew Thauer (@andrewthauer, Wealthsimple) +- Oliver Sand (@Fox32, SDA-SE) diff --git a/README.md b/README.md index 9a1622cda7..9ef09aee06 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ ![](https://github.com/spotify/backstage/workflows/Frontend%20CI/badge.svg) [![Discord](https://img.shields.io/discord/687207715902193673)](https://discord.gg/EBHEGzX) ![Code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg) +[![Codecov](https://img.shields.io/codecov/c/github/spotify/backstage)](https://codecov.io/gh/spotify/backstage) [![](https://img.shields.io/npm/v/@backstage/core?label=Version)](https://github.com/spotify/backstage/releases) ## What is Backstage? @@ -18,81 +19,35 @@ Backstage unifies all your infrastructure tooling, services, and documentation t Out of the box, Backstage includes: -- [Backstage Service Catalog](https://github.com/spotify/backstage/blob/master/docs/features/software-catalog/index.md) for managing all your software (microservices, libraries, data pipelines, websites, ML models, etc.) -- [Backstage Software Templates](https://github.com/spotify/backstage/blob/master/docs/features/software-templates/index.md) for quickly spinning up new projects and standardizing your tooling with your organization’s best practices -- [Backstage TechDocs](https://github.com/spotify/backstage/tree/master/docs/features/techdocs) for making it easy to create, maintain, find, and use technical documentation, using a "docs like code" approach +- [Backstage Service Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview) for managing all your software (microservices, libraries, data pipelines, websites, ML models, etc.) +- [Backstage Software Templates](https://backstage.io/docs/features/software-templates/software-templates-index) for quickly spinning up new projects and standardizing your tooling with your organization’s best practices +- [Backstage TechDocs](https://backstage.io/docs/features/techdocs/techdocs-overview) for making it easy to create, maintain, find, and use technical documentation, using a "docs like code" approach - Plus, a growing ecosystem of [open source plugins](https://github.com/spotify/backstage/tree/master/plugins) that further expand Backstage’s customizability and functionality For more information go to [backstage.io](https://backstage.io) or join our [Discord chatroom](https://discord.gg/EBHEGzX). ## Project roadmap -We created Backstage about 4 years ago. While our internal version of Backstage has had the benefit of time to mature and evolve, the first iteration of our open source version is still nascent. We are envisioning three phases of the project and we have already begun work on various aspects of these phases: - -- 🐣 **Phase 1:** Extensible frontend platform (Done ✅) - You will be able to easily create a single consistent UI layer for your internal infrastructure and tools. A set of reusable [UX patterns and components](http://backstage.io/storybook) help ensure a consistent experience between tools. - -- 🐢 **Phase 2:** Service Catalog ([alpha released](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha)) - With a single catalog, Backstage makes it easy for a team to manage ten services — and makes it possible for your company to manage thousands of them. Developers can get a uniform overview of all their software and related resources, regardless of how and where they are running, as well as an easy way to onboard and manage those resources. - -- 🐇 **Phase 3:** Ecosystem (later) - Everyone's infrastructure stack is different. By fostering a vibrant community of contributors we hope to provide an ecosystem of Open Source plugins/integrations that allows you to pick the tools that match your stack. - -Check out our [Milestones](https://github.com/spotify/backstage/milestones) and open [RFCs](https://github.com/spotify/backstage/labels/rfc) how they relate to the three Phases outlined above. - -Our vision for Backstage is for it to become the trusted standard toolbox (read: UX layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. If this sounds interesting or you'd like to help us shape our product vision, we'd love to talk. You can email me directly: [alund@spotify.com](mailto:alund@spotify.com). - -## Overview - -The Backstage platform consists of a number of different components: - -- **app** - Main web application that users interact with. It's built up by a number of different _Plugins_. This repo contains an example implementation of an app (located in `packages/app`) and you can easily get started with your own app by [creating one](docs/getting-started/create-an-app.md). -- [**plugins**](https://github.com/spotify/backstage/tree/master/plugins) - Each plugin is treated as a self-contained web app and can include almost any type of content. Plugins all use a common set of platform API's and reusable UI components. Plugins can fetch data either from the _backend_ or through any RESTful API exposed through the _proxy_. -- [**service catalog**](https://github.com/spotify/backstage/tree/master/packages/backend) - Service that holds the model of your software ecosystem, including organisational information and what team owns what software. The backend also has a Plugin model for extending its graph. -- [**proxy**](https://github.com/spotify/backstage/tree/master/plugins/proxy-backend) - Terminates HTTPS and exposes any RESTful API to Plugins. -- **identity** - A backend service that holds your organisation's metadata. +A detailed project roadmap, including already delivered milestones, is available [here](https://backstage.io/docs/overview/roadmap). ## Getting Started -There are two different ways to get started with Backstage, either by creating a standalone app, or by cloning this repo. Which method you use depends on what you're planning to do. - -Creating a standalone instance makes it simpler to customize the application for your needs whilst staying up to date with the project. You will also depend on `@backstage` packages from NPM, making the project much smaller. This is the recommended approach if you want to kick the tyres of Backstage or setup your own instance. - -On the other hand, if you want to contribute plugins or to the project in general, it's easier to fork and clone this project. That will let you stay up to date with the latest changes, and gives you an easier path to make Pull Requests towards this repo. - -### Creating a Standalone App - -Backstage provides the `@backstage/create-app` package to scaffold standalone instances of Backstage. You will need to have -[NodeJS](https://nodejs.org/en/download/) Active LTS Release installed -(currently v12), and [yarn](https://classic.yarnpkg.com/en/docs/install). You will also need to have [Docker](https://docs.docker.com/engine/install/) installed to use some features like Software Templates and TechDocs. - -Using `npx` you can then run the following to create an app in a chosen subdirectory of your current working directory: - -```bash -npx @backstage/create-app -``` - -You will be taken through a wizard to create your app, and the output should look something like this. You can read more about this process [here](docs/getting-started/create-an-app.md) - -### Contributing to Backstage - -You can read more in our [CONTRIBUTING.md](./CONTRIBUTING.md#get-started) guide, which can help you get setup with a Backstage development environment. - -### Next steps - -Take a look at the [Getting Started](docs/getting-started/index.md) guide to learn how to set up Backstage, and how to develop on the platform. +Check out [the documentation](https://backstage.io/docs/getting-started) on how to start using Backstage. ## Documentation -- [Main documentation](docs/README.md) -- [Service Catalog](docs/features/software-catalog/index.md) -- [Architecture](docs/overview/architecture-terminology.md) ([Decisions](docs/architecture-decisions/index.md)) -- [Designing for Backstage](docs/dls/design.md) -- [Storybook - UI components](http://backstage.io/storybook) +- [Main documentation](https://backstage.io/docs) +- [Service Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview) +- [Architecture](https://backstage.io/docs/overview/architecture-terminology) ([Decisions](https://backstage.io/docs/architecture-decisions/adrs-overview)) +- [Designing for Backstage](https://backstage.io/docs/dls/design) +- [Storybook - UI components](https://backstage.io/storybook) ## Community - [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the project - [Good First Issues](https://github.com/spotify/backstage/contribute) - Start here if you want to contribute - [RFCs](https://github.com/spotify/backstage/labels/rfc) - Help shape the technical direction -- [FAQ](docs/FAQ.md) - Frequently Asked Questions +- [FAQ](https://backstage.io/docs/FAQ) - Frequently Asked Questions - [Code of Conduct](CODE_OF_CONDUCT.md) - This is how we roll - [Adopters](ADOPTERS.md) - Companies already using Backstage - [Blog](https://backstage.io/blog/) - Announcements and updates diff --git a/app-config.development.yaml b/app-config.development.yaml new file mode 100644 index 0000000000..da274ba1a8 --- /dev/null +++ b/app-config.development.yaml @@ -0,0 +1,11 @@ +app: + baseUrl: http://localhost:3000 + +backend: + baseUrl: http://localhost:7000 + listen: + port: 7000 + cors: + origin: http://localhost:3000 + methods: [GET, POST, PUT, DELETE] + credentials: true diff --git a/app-config.yaml b/app-config.yaml index 92b35da31d..e018eef87e 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -1,38 +1,40 @@ app: title: Backstage Example App - baseUrl: http://localhost:3000 + baseUrl: http://localhost:7000 backend: baseUrl: http://localhost:7000 listen: - host: 0.0.0.0 port: 7000 - cors: - origin: http://localhost:3000 - methods: [GET, POST, PUT, DELETE] - credentials: true + database: + client: sqlite3 + connection: ':memory:' +# See README.md in the proxy-backend plugin for information on the configuration format proxy: '/circleci/api': - target: 'https://circleci.com/api/v1.1' + target: https://circleci.com/api/v1.1 changeOrigin: true pathRewrite: '^/proxy/circleci/api/': '/' + headers: + Circle-Token: + $secret: + env: CIRCLECI_AUTH_TOKEN + '/jenkins/api': - target: 'http://localhost:8080' - changeOrigin: true + target: http://localhost:8080 headers: Authorization: $secret: env: JENKINS_BASIC_AUTH_HEADER - pathRewrite: - '^/proxy/jenkins/api/': '/' organization: name: Spotify techdocs: storageUrl: http://localhost:7000/techdocs/static/docs + requestUrl: http://localhost:7000/techdocs/docs sentry: organization: spotify @@ -48,12 +50,83 @@ newrelic: baseUrl: 'https://api.newrelic.com/v2' key: NEW_RELIC_REST_API_KEY +lighthouse: + baseUrl: http://localhost:3003 + +catalog: + rules: + - allow: [Component, API, Group, Template, Location] + processors: + github: + privateToken: + $secret: + env: GITHUB_PRIVATE_TOKEN + githubApi: + providers: + - target: https://github.com + token: + $secret: + env: GITHUB_PRIVATE_TOKEN + # Example for how to add your GitHub Enterprise instance: + # - target: https://ghe.example.net + # apiBaseUrl: https://ghe.example.net/api/v3 + # token: + # $secret: + # env: GHE_PRIVATE_TOKEN + bitbucketApi: + username: + $secret: + env: BITBUCKET_USERNAME + appPassword: + $secret: + env: BITBUCKET_APP_PASSWORD + gitlabApi: + privateToken: + $secret: + env: GITLAB_PRIVATE_TOKEN + azureApi: + privateToken: + $secret: + env: AZURE_PRIVATE_TOKEN + + locations: + # Backstage example components + - type: github + target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/all-components.yaml + + # Example component for github-actions + - type: github + target: https://github.com/spotify/backstage/blob/master/plugins/github-actions/examples/sample.yaml + + # Example component for techdocs + - type: github + target: https://github.com/spotify/backstage/blob/master/plugins/techdocs-backend/examples/documented-component/documented-component.yaml + + # Backstage example APIs + - type: github + target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml + + # Backstage example templates + - type: github + target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/all-templates.yaml + +scaffolder: + github: + token: + $secret: + env: GITHUB_ACCESS_TOKEN + visibility: public # or 'internal' or 'private' + gitlab: + api: + baseUrl: https://gitlab.com + token: + $secret: + env: GITLAB_ACCESS_TOKEN + auth: providers: google: development: - appOrigin: "http://localhost:3000/" - secure: false clientId: $secret: env: AUTH_GOOGLE_CLIENT_ID @@ -62,8 +135,6 @@ auth: env: AUTH_GOOGLE_CLIENT_SECRET github: development: - appOrigin: "http://localhost:3000/" - secure: false clientId: $secret: env: AUTH_GITHUB_CLIENT_ID @@ -75,8 +146,6 @@ auth: env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL gitlab: development: - appOrigin: "http://localhost:3000/" - secure: false clientId: $secret: env: AUTH_GITLAB_CLIENT_ID @@ -86,14 +155,11 @@ auth: audience: $secret: env: GITLAB_BASE_URL - # saml: - # development: - # entryPoint: "http://localhost:7001/" - # issuer: "passport-saml" + saml: + entryPoint: 'http://localhost:7001/' + issuer: 'passport-saml' okta: development: - appOrigin: "http://localhost:3000/" - secure: false clientId: $secret: env: AUTH_OKTA_CLIENT_ID @@ -105,17 +171,37 @@ auth: env: AUTH_OKTA_AUDIENCE oauth2: development: - appOrigin: "http://localhost:3000/" - secure: false clientId: $secret: env: AUTH_OAUTH2_CLIENT_ID clientSecret: $secret: env: AUTH_OAUTH2_CLIENT_SECRET - authorizationURL: + authorizationUrl: $secret: env: AUTH_OAUTH2_AUTH_URL - tokenURL: + tokenUrl: $secret: env: AUTH_OAUTH2_TOKEN_URL + auth0: + development: + clientId: + $secret: + env: AUTH_AUTH0_CLIENT_ID + clientSecret: + $secret: + env: AUTH_AUTH0_CLIENT_SECRET + domain: + $secret: + env: AUTH_AUTH0_DOMAIN + microsoft: + development: + clientId: + $secret: + env: AUTH_MICROSOFT_CLIENT_ID + clientSecret: + $secret: + env: AUTH_MICROSOFT_CLIENT_SECRET + tenantId: + $secret: + env: AUTH_MICROSOFT_TENANT_ID diff --git a/catalog-info.yaml b/catalog-info.yaml index 86b67f95cd..937a55bfca 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -3,10 +3,10 @@ kind: Component metadata: name: backstage description: | - Backstage is an open-source developer portal that puts the developer experience first. + Backstage is an open-source developer portal that puts the developer experience first. annotations: github.com/project-slug: spotify/backstage - backstage.io/github-actions-id: spotify/backstage + backstage.io/techdocs-ref: github:https://github.com/spotify/backstage.git spec: type: library owner: Spotify diff --git a/docker-compose.yaml b/docker-compose.yaml index d32928b4e3..a9922cfb6c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,14 +1,10 @@ # Make sure that before you # run the docker-compose that you have run -# $ yarn docker-build:all +# $ yarn docker-build version: '3' services: - frontend: - image: 'spotify/backstage:latest' - ports: - - '3000:80' - backend: + backstage: image: 'example-backend:latest' ports: - '7000:7000' diff --git a/docs/FAQ.md b/docs/FAQ.md index d230e682a4..b4c4970ff8 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -1,9 +1,10 @@ --- id: FAQ title: FAQ +description: All FAQ related to Backstage --- -## Product FAQ: +## Product FAQ ### Can we call Backstage something different? So that it fits our company better? @@ -67,7 +68,7 @@ valuable as you grow. Yes! The Backstage UI is built using Material-UI. With the theming capabilities of Material-UI, you are able to adapt the interface to your brand guidelines. -## Technical FAQ: +## Technical FAQ ### Why Material-UI? diff --git a/docs/README.md b/docs/README.md index 9bbc69ca6d..c63a12b589 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,103 +1,3 @@ -# Documentation structure +# Documentation -**Note!** This documentation structure is very much work in progress. If (when, -really 😆) you find broken links or missing content, please create an issue or, -better yet, a pull request. - -# Plugins - -- Overview - - [What is Backstage?](overview/what-is-backstage.md) - - [Backstage architecture](overview/architecture-overview.md) - - [Architecture and terminology](overview/architecture-terminology.md) - - [Roadmap](overview/roadmap.md) -- Getting started - - [Running Backstage locally](getting-started/index.md) - - [Installation](getting-started/installation.md) - - [Local development](getting-started/development-environment.md) - - [Demo deployment](https://backstage-demo.roadie.io) - - Production deployments - - [Create an App](getting-started/create-an-app.md) - - App configuration - - [Configuring App with plugins](getting-started/configure-app-with-plugins.md) - - [Customize the look-and-feel of your App](getting-started/app-custom-theme.md) - - Deployment scenarios - - [Kubernetes](getting-started/deployment-k8s.md) - - [Other](getting-started/deployment-other.md) -- Features - - Software Catalog - - [Overview](features/software-catalog/index.md) - - [System model](features/software-catalog/system-model.md) - - [YAML File Format](features/software-catalog/descriptor-format.md) - - [Extending the model](features/software-catalog/extending-the-model.md) - - [External integrations](features/software-catalog/external-integrations.md) - - [API](features/software-catalog/api.md) - - Software creation templates - - [Overview](features/software-templates/index.md) - - [Adding templates](features/software-templates/adding-templates.md) - - Extending the Scaffolder: - - [Overview](features/software-templates/extending/index.md) - - [Create your own Templater](features/software-templates/extending/create-your-own-templater.md) - - [Create your own Publisher](features/software-templates/extending/create-your-own-publisher.md) - - [Create your own Preparer](features/software-templates/extending/create-your-own-preparer.md) - - Docs-like-code - - [Overview](features/techdocs/README.md) - - [Getting Started](features/techdocs/getting-started.md) - - [Concepts](features/techdocs/concepts.md) - - [Creating and Publishing Documentation](features/techdocs/creating-and-publishing.md) - - [FAQ](features/techdocs/FAQ.md) -- Plugins - - [Overview](plugins/index.md) - - [Existing plugins](plugins/existing-plugins.md) - - [Creating a new plugin](plugins/create-a-plugin.md) - - [Developing a plugin](plugins/plugin-development.md) - - [Structure of a plugin](plugins/structure-of-a-plugin.md) - - Backends and APIs - - [Proxying](plugins/proxying.md) - - [Backstage backend plugin](plugins/backend-plugin.md) - - [Call existing API](plugins/call-existing-api.md) - - Testing - - [Overview](plugins/testing.md) - - Publishing - - [Open source and NPM](plugins/publishing.md) - - [Private/internal (non-open source)](plugins/publish-private.md) -- Configuration - - [Overview](conf/index.md) - - [Reading Configuration](conf/reading.md) - - [Writing Configuration](conf/writing.md) - - [Defining Configuration](conf/defining.md) -- Authentication and identity - - [Overview](auth/index.md) - - [Add auth provider](auth/add-auth-provider.md) - - [Auth backend](auth/auth-backend.md) - - [OAuth](auth/oauth.md) - - [Glossary](auth/glossary.md) -- Designing for Backstage - - [Backstage Design Language System (DLS)](dls/design.md) - - [Storybook -- reusable UI components](http://backstage.io/storybook) - - [Contributing to Storybook](dls/contributing-to-storybook.md) - - [Figma resources](dls/figma.md) -- API references - - TypeScript API - - [Utility APIs](api/utility-apis.md) - - [Utility API References](reference/utility-apis/README.md) - - [createPlugin](reference/createPlugin.md) - - [createPlugin-feature-flags](reference/createPlugin-feature-flags.md) - - [createPlugin-router](reference/createPlugin-router.md) - - Backend APIs - - [Backend](api/backend.md) -- Tutorials - - [Overview](tutorials/index.md) -- Architecture Decision Records (ADRs) - - [Overview](architecture-decisions/index.md) - - [ADR001 - Architecture Decision Record (ADR) log](architecture-decisions/adr001-add-adr-log.md) - - [ADR002 - Default Software Catalog File Format](architecture-decisions/adr002-default-catalog-file-format.md) - - [ADR003 - Avoid Default Exports and Prefer Named Exports](architecture-decisions/adr003-avoid-default-exports.md) - - [ADR004 - Module Export Structure](architecture-decisions/adr004-module-export-structure.md) - - [ADR005 - Catalog Core Entities](architecture-decisions/adr005-catalog-core-entities.md) - - [ADR006 - Avoid React.FC and React.SFC](architecture-decisions/adr006-avoid-react-fc.md) - - [ADR007 - Use MSW for Mocking Network Requests](architecture-decisions/adr007-use-msw-to-mock-service-requests.md) - - [ADR008 - Default Catalog File Name](architecture-decisions/adr008-default-catalog-file-name.md) -- [Contribute](../CONTRIBUTING.md) -- [Support](overview/support.md) -- [FAQ](FAQ.md) +The Backstage documentation is available at https://backstage.io/docs diff --git a/docs/api/backend.md b/docs/api/backend.md index bc44b8350c..0990343a01 100644 --- a/docs/api/backend.md +++ b/docs/api/backend.md @@ -1,6 +1,7 @@ --- id: backend title: Backend +description: About Backend --- ## TODO diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index ff8c91e178..d192cbd6ee 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -1,6 +1,7 @@ --- id: utility-apis title: Utility APIs +description: Backstage Utility APIs --- ## Introduction @@ -17,7 +18,7 @@ Utility APIs. While the `createPlugin` API is focused on the initialization plugins and the app, the Utility APIs provide ways for plugins to communicate during their entire life cycle. -## Usage +## Consuming APIs Each Utility API is tied to an `ApiRef` instance, which is a global singleton object without any additional state or functionality, its only purpose is to @@ -55,47 +56,112 @@ from any component inside Backstage, including the ones in `@backstage/core`. The only requirement is that they are beneath the `AppProvider` in the react tree. -## Registering Utility API Implementations +## Supplying APIs -The Backstage App is responsible for providing implementations for all Utility -APIs required by plugins. The example app in this repo registers its APIs inside -[src/apis.ts](/packages/app/src/apis.ts). Here's an example of how to wire up -the `ErrorApi` inside an app: +### API Factories + +APIs are registered in the form of `ApiFactories`, which encapsulate the process +of instantiating an API. It is a collection of three things: the `ApiRef` of the +API to instantiate, a list of all required dependencies, and a factory function +that returns a new API instance. + +For example, this is the default `ApiFactory` for the `ErrorApi`: ```ts -import { - ApiRegistry, - createApp, - alertApiRef, - errorApiRef, - AlertApiForwarder, - ErrorApiForwarder, - ErrorAlerter, -} from '@backstage/core'; - -const builder = ApiRegistry.builder(); - -// The alert API is a self-contained implementation that shows alerts to the user. -const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); - -// The error API uses the alert API to send error notifications to the user. -builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); - -const app = createApp({ - apis: apiBuilder.build(), - // ... other config +createApiFactory({ + api: errorApiRef, + deps: { alertApi: alertApiRef }, + factory: ({ alertApi }) => + new ErrorAlerter(alertApi, new ErrorApiForwarder()), }); ``` -The `ApiRegistry` is used to register all Utility APIs in the app and associate -them with `ApiRef`s. It implements the `ApiHolder` interface, which enables it -to provide an API implementation given an `ApiRef`. +In this example the `errorApiRef` is our API, which encapsulates the `ErrorApi` +type. The `alertApiRef` is our single dependency, which we give the name +`alertApi`, and is then passed on to the factory function, which returns an +implementation of the `ErrorApi`. -Note that our `ErrorApi` implementation depends on another Utility API, the -`AlertApi`. This is the method with which APIs can depend on other APIs, using -manual dependency injection at the initialization of the app. In general, if you -want to depend on another Utility API in an implementation of an API, you import -the type for that API and make it a constructor parameter. +The `createApiFactory` function is a thin wrapper that enables TypeScript type +inference. You may notice that there are no type annotations in the above +example, and that is because we're able to infer all types from the `ApiRef`s. +TypeScript will make sure that the return value of the `factory` function +matches the type embedded in `api`'s `ApiRef`, in this case the `ErrorApi`. It +will also match the types between the `deps` and the parameters of the `factory` +function, again using the type embedded within the `ApiRef`s. + +## Registering API Factories + +The responsibility for adding Utility APIs to a Backstage app lies in three +different locations: the Backstage core library, each plugin included in the +app, and the app itself. + +### Core APIs + +Starting with the Backstage core library, it provides implementation for all of +the core APIs. The core APIs are the ones exported by `@backstage/core`, such as +the `errorApiRef` and `configApiRef`. You can find a full list of them +[here](../reference/utility-apis/README.md). + +The core APIs are loaded for any app created with `createApp` from +`@backstage/core`, which means that there is no step that needs to be taken to +include these APIs in an app. + +### Plugin APIs + +In addition to the core APIs, plugins can define and export their own APIs. +While doing so they should usually also provide default implementations of their +own APIs, for example, the `catalog` plugin exports `catalogApiRef`, and also +supplies a default `ApiFactory` of that API using the `CatalogClient`. There is +one restriction to plugin-provided API Factories: plugins may not supply +factories for core APIs, trying to do so will cause the app to crash. + +Plugins supply their APIs through the `apis` option of `createPlugin`, for +example: + +```ts +export const plugin = createPlugin({ + id: 'techdocs', + apis: [ + createApiFactory({ + api: techdocsStorageApiRef, + deps: { configApi: configApiRef }, + factory({ configApi }) { + return new TechDocsStorageApi({ + apiOrigin: configApi.getString('techdocs.storageUrl'), + }); + }, + }), + ], +}); +``` + +### App APIs + +Lastly, the app itself is the final point where APIs can be added, and what has +the final say in what APIs will be loaded at runtime. The app may override the +factories for any of the core or plugin APIs, with the exception of the config, +app theme, and identity APIs. These are static APIs that are tied into the +`createApp` implementation, and therefore not possible to override. + +Overriding APIs is useful for apps that want to switch out behavior to tailor it +to their environment. In some cases plugins may also export multiple +implementations of the same API, where they each have their own different +requirements on for example backend storage and surrounding environment. + +Supplying APIs to the app works just like for plugins: + +```ts +const app = createApp({ + apis: [ + /* ApiFactories */ + ], + // ... other options +}); +``` + +A common pattern is to export a list of all APIs from `apis.ts`, next to +`App.tsx`. See the [example app in this repo](../../packages/app/src/apis.ts) +for an example. ## Custom implementations of Utility APIs @@ -123,19 +189,33 @@ implement the `ErrorApi`, as it is checked by the type embedded in the ## Defining custom Utility APIs -The pattern for plugins defining their own Utility APIs is not fully established -yet. The current way is for the plugin to export its own `ApiRef` and type for -the API, along with one or more implementations. It is then up to the app to -import, and register those APIs. See for example the -[lighthouse](/plugins/lighthouse/src/api.ts) or -[graphiql](/plugins/graphiql/src/lib/api/types.ts) plugins for examples of this. +Plugins are free to define their own Utility APIs. Simply define the TypeScript +interface for the API, and create an `ApiRef` using `createApiRef` exported from +`@backstage/core`. Also be sure to provide at least one implementation of the +API, and to declare a default factory for the API in `createPlugin`. -The goal is to make this process a bit smoother, but that requires work in other -parts of Backstage, like configuration management. So it remains as a TODO. If -you have more questions regarding this, or have an idea for an API that you want -to share outside your plugin, hit us up in -[GitHub issues](https://github.com/spotify/backstage/issues/new/choose) or the -[Backstage Discord server](https://discord.gg/EBHEGzX). +Custom Utility APIs can be either public or private, which it is up to the +plugin to choose. Private APIs do not expose an external API surface, and it's +therefore possible to make breaking changes to the API without affecting other +users of the plugin. If an API is made public however, it opens up for other +plugins to make use of the API, and it also makes it possible for users for your +plugin to override the API in the app. It is however important to maintain +backwards compatibility of public APIs, as you may otherwise break apps that are +using your plugin. + +To make an API public, simply export the `ApiRef` of the API, and any associated +types. To make an API private, just avoid exporting the `ApiRef`, but still be +sure to supply a default factory to `createPlugin`. + +Private APIs are useful for plugins that want to depend on other APIs outside of +React components, but not have to expose an entire API surface to maintain. When +using private APIs, it is fine to use the `typeof` of an implementing class as +the type parameter passed to `createApiRef`, while public APIs should always +define a separate TypeScript interface type. + +Plugins may depend on APIs from other plugins, both in React components and as +dependencies to API factories. Do however be sure to not cause circular +dependencies between plugins. ## Architecture diff --git a/docs/architecture-decisions/adr001-add-adr-log.md b/docs/architecture-decisions/adr001-add-adr-log.md index 8a484fa264..453905ab7a 100644 --- a/docs/architecture-decisions/adr001-add-adr-log.md +++ b/docs/architecture-decisions/adr001-add-adr-log.md @@ -2,6 +2,7 @@ id: adrs-adr001 title: ADR001: Architecture Decision Record (ADR) log sidebar_label: ADR001 +description: Architecture Decision Record (ADR) logs as a reference point for the team --- | Created | Status | diff --git a/docs/architecture-decisions/adr002-default-catalog-file-format.md b/docs/architecture-decisions/adr002-default-catalog-file-format.md index c1cb719bf5..da98aafaed 100644 --- a/docs/architecture-decisions/adr002-default-catalog-file-format.md +++ b/docs/architecture-decisions/adr002-default-catalog-file-format.md @@ -2,6 +2,7 @@ id: adrs-adr002 title: ADR002: Default Software Catalog File Format sidebar_label: ADR002 +description: Architecture Decision Record (ADR) log on Default Software Catalog File Format --- | Created | Status | @@ -68,7 +69,7 @@ metadata: lifecycle: production example.com/service-discovery-name: frobsawesome annotations: - circleci.com/project-slug: gh/example-org/frobs-awesome + circleci.com/project-slug: github/example-org/frobs-awesome spec: type: service ``` diff --git a/docs/architecture-decisions/adr003-avoid-default-exports.md b/docs/architecture-decisions/adr003-avoid-default-exports.md index 7a8df28f49..9d1f9c83a4 100644 --- a/docs/architecture-decisions/adr003-avoid-default-exports.md +++ b/docs/architecture-decisions/adr003-avoid-default-exports.md @@ -2,6 +2,7 @@ id: adrs-adr003 title: ADR003: Avoid Default Exports and Prefer Named Exports sidebar_label: ADR003 +description: Architecture Decision Record (ADR) log on Avoid Default Exports and Prefer Named Exports --- | Created | Status | diff --git a/docs/architecture-decisions/adr004-module-export-structure.md b/docs/architecture-decisions/adr004-module-export-structure.md index 077f8c35d1..3dbda3f8b9 100644 --- a/docs/architecture-decisions/adr004-module-export-structure.md +++ b/docs/architecture-decisions/adr004-module-export-structure.md @@ -2,6 +2,7 @@ id: adrs-adr004 title: ADR004: Module Export Structure sidebar_label: ADR004 +description: Architecture Decision Record (ADR) log on Module Export Structure --- | Created | Status | diff --git a/docs/architecture-decisions/adr005-catalog-core-entities.md b/docs/architecture-decisions/adr005-catalog-core-entities.md index 8f39acd1cf..ae615f647d 100644 --- a/docs/architecture-decisions/adr005-catalog-core-entities.md +++ b/docs/architecture-decisions/adr005-catalog-core-entities.md @@ -2,6 +2,7 @@ id: adrs-adr005 title: ADR005: Catalog Core Entities sidebar_label: ADR005 +description: Architecture Decision Record (ADR) log on Catalog Core Entities --- | Created | Status | diff --git a/docs/architecture-decisions/adr006-avoid-react-fc.md b/docs/architecture-decisions/adr006-avoid-react-fc.md index e48fceb562..696cdcf537 100644 --- a/docs/architecture-decisions/adr006-avoid-react-fc.md +++ b/docs/architecture-decisions/adr006-avoid-react-fc.md @@ -2,6 +2,7 @@ id: adrs-adr006 title: ADR006: Avoid React.FC and React.SFC sidebar_label: ADR006 +description: Architecture Decision Record (ADR) log on Avoid React.FC and React.SFC --- ## Context diff --git a/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md b/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md index 773f08be30..d91af19068 100644 --- a/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md +++ b/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md @@ -2,6 +2,7 @@ id: adrs-adr007 title: ADR007: Use MSW to mock http requests sidebar_label: ADR007 +description: Architecture Decision Record (ADR) log on Use MSW to mock http requests --- ## Context diff --git a/docs/architecture-decisions/adr008-default-catalog-file-name.md b/docs/architecture-decisions/adr008-default-catalog-file-name.md index 979ea4de33..01afefd690 100644 --- a/docs/architecture-decisions/adr008-default-catalog-file-name.md +++ b/docs/architecture-decisions/adr008-default-catalog-file-name.md @@ -2,6 +2,7 @@ id: adrs-adr008 title: ADR008: Default Catalog File Name sidebar_label: ADR008 +description: Architecture Decision Record (ADR) log on Default Catalog File Name --- ## Background diff --git a/docs/architecture-decisions/index.md b/docs/architecture-decisions/index.md index b02e8deabb..ddf2805e75 100644 --- a/docs/architecture-decisions/index.md +++ b/docs/architecture-decisions/index.md @@ -2,11 +2,10 @@ id: adrs-overview title: Architecture Decision Records (ADR) sidebar_label: Overview +description: Overview of Architecture Decision Records (ADR) --- -# - -The substantial architecture decisions made in the Backstage project lives here. +The substantial architecture decisions made in the Backstage project live here. For more information about ADRs, when to write them, and why, please see [this blog post](https://engineering.atspotify.com/2020/04/14/when-should-i-write-an-architecture-decision-record/). @@ -25,7 +24,10 @@ Records should be stored under the `architecture-decisions` directory. - Submit a pull request - Address and integrate feedback from the community - Eventually, assign a number -- Add the full path of the ADR to the [`mkdocs.yml`](/mkdocs.yml) +- Add the path of the ADR to the microsite sidebar in + [`sidebars.json`](https://github.com/spotify/backstage/blob/master/microsite/sidebars.json) +- Add the path of the ADR to the + [`mkdocs.yml`](https://github.com/spotify/backstage/blob/master/mkdocs.yml) - Merge the pull request ## Superseding an ADR diff --git a/docs/auth/oauth-popup-flow.svg b/docs/assets/auth/oauth-popup-flow.svg similarity index 98% rename from docs/auth/oauth-popup-flow.svg rename to docs/assets/auth/oauth-popup-flow.svg index 4d9e79787a..2132903783 100644 --- a/docs/auth/oauth-popup-flow.svg +++ b/docs/assets/auth/oauth-popup-flow.svg @@ -1,5 +1,5 @@ -OAuth Consent and Refresh FlowBrowserBrowserPopup WindowPopup Windowauth-backend pluginauth-backend pluginConsent ScreenConsent ScreenOAuth ProviderOAuth ProviderComponents on page ask for anaccess token with greaterscope than the existing session.Open popupGET /auth/<provider>/start?scope=some%20scopesRedirect to consent screen withrandom nonce in OAuth state andshort-lived cookie with the same nonce.GET /consent_url?redirect_uri=<redirect_uri>?nonce=<n>where redirect_uri=<app-origin>/auth/<provider>/handler/frameUser consents toaccess the new scope.Redirect to given redirect URL, with authorization codeGET /auth/<provider>/handler/frame?code=<c>&nonce=<n>Request includes the previously set none cookieVerify that the nonce in the cookiematches the nonce in the OAuth stateAuthorization CodeClient IDClient SecretVerify and generate tokensAccess Token(ID Token)(Refresh Token)ScopeExpire TimeSmall HTML page with inlined response payloadStore Refresh Token in HTTP-only cookiepostMessage() with tokens and info or errorClose selfA later point when a refreshis needed. Either because ofa reload or an expiring session.GET /auth/<provider>/tokenRefresh Token cookie includedRefresh TokenClient IDClient SecretAccess Token(ID Token)ScopeExpire TimeTokens and info - -![](oauth-popup-flow.svg) diff --git a/docs/conf/defining.md b/docs/conf/defining.md index b28d6b8362..bea03e4e44 100644 --- a/docs/conf/defining.md +++ b/docs/conf/defining.md @@ -1,6 +1,7 @@ --- id: defining title: Defining Configuration for your Plugin +description: Documentation on Defining Configuration for your Plugin --- There is currently no tooling support or helpers for defining plugin diff --git a/docs/conf/index.md b/docs/conf/index.md index bc1d7e8e1f..83eff50a7d 100644 --- a/docs/conf/index.md +++ b/docs/conf/index.md @@ -1,6 +1,7 @@ --- id: index title: Static Configuration in Backstage +description: Documentation on Static Configuration in Backstage --- ## Summary diff --git a/docs/conf/reading.md b/docs/conf/reading.md index 76d1f7f6ea..a43ec8c026 100644 --- a/docs/conf/reading.md +++ b/docs/conf/reading.md @@ -1,6 +1,7 @@ --- id: reading title: Reading Backstage Configuration +description: Documentation on Reading Backstage Configuration --- ## Config API diff --git a/docs/conf/writing.md b/docs/conf/writing.md index da2a10a86d..6d338e586d 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -1,6 +1,7 @@ --- id: writing title: Writing Backstage Configuration Files +description: Documentation on Writing Backstage Configuration Files --- ## File Format diff --git a/docs/dls/contributing-to-storybook.md b/docs/dls/contributing-to-storybook.md index f0c2201a98..058f4a23a7 100644 --- a/docs/dls/contributing-to-storybook.md +++ b/docs/dls/contributing-to-storybook.md @@ -1,6 +1,7 @@ --- id: contributing-to-storybook title: Contributing to Storybook +description: Documentation on How to Contribute to Storybook --- You find our storybook at diff --git a/docs/dls/design.md b/docs/dls/design.md index c8a94fad4d..fa3a0c159e 100644 --- a/docs/dls/design.md +++ b/docs/dls/design.md @@ -1,9 +1,10 @@ --- id: design title: Design +description: Documentation on Design --- -![header](../assets/dls/designheader.png) +![header](../assets/dls/designheader-updated.png) Much like Backstage Open Source, this is a _living_ document! We'll keep this updated as we evolve our practices! @@ -116,8 +117,8 @@ components. If you’d like to help build up our design system, you can also add components we’ve designed to the Storybook as well. **[Figma](https://www.figma.com/@backstage)** - we're stoked to be using Figma -Community to share our design assets. You can duplicate our component library -and design your own plugin for Backstage. +Community to share our design assets. You can duplicate our UI Kit and design +your own plugin for Backstage. **[Discord](https://discord.gg/EBHEGzX)** - all design questions should be directed to the _#design_ channel. diff --git a/docs/dls/figma.md b/docs/dls/figma.md index 14de2f5980..c5a33d15c2 100644 --- a/docs/dls/figma.md +++ b/docs/dls/figma.md @@ -1,6 +1,8 @@ --- id: figma title: Figma +description: Documentation on using Figma to build your own plugins for +Backstage --- We have a [Figma component library](https://www.figma.com/@backstage) that you diff --git a/docs/features/software-catalog/api.md b/docs/features/software-catalog/api.md index 332c1e505b..f7c7b19112 100644 --- a/docs/features/software-catalog/api.md +++ b/docs/features/software-catalog/api.md @@ -1,6 +1,7 @@ --- id: software-catalog-api title: API +description: Documentation on Software Catalog API --- ## TODO diff --git a/docs/features/software-catalog/configuration.md b/docs/features/software-catalog/configuration.md new file mode 100644 index 0000000000..0bcfaabe0a --- /dev/null +++ b/docs/features/software-catalog/configuration.md @@ -0,0 +1,59 @@ +--- +id: software-catalog-configuration +title: Catalog Configuration +description: Documentation on Software Catalog Configuration +--- + +## Static Location Configuration + +To enable declarative catalog setups, it is possible to add locations to the +catalog via [static configuration](../../conf/index.md). Locations are added to +the catalog under the `catalog.locations` key, for example: + +```yaml +catalog: + locations: + - type: github + target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml +``` + +The locations added through static configuration can not be removed through the +catalog locations API. To remove the locations, you have to remove them from the +configuration. + +## Catalog Rules + +By default the catalog will only allow ingestion of entities with the kind +`Component`, `API` and `Location`. In order to allow entities of other kinds to +be added, you need to add rules to the catalog. Rules are added either in a +separate `catalog.rules` key, or added to statically configured locations. + +For example, given the following configuration: + +```yaml +catalog: + rules: + - allow: [Component, API, Location, Template] + + locations: + - type: github + target: https://github.com/org/example/blob/master/org-data.yaml + rules: + - allow: [Group] +``` + +We are able to add entities of kind `Component`, `API`, `Location`, or +`Template` from any location, and `Group` entities from the `org-data.yaml`, +which will also be read as statically configured location. + +Note that if the `catalog.rules` key is present it will replace the default +value, meaning that you need to add rules for the default kinds if you want +those to still be allowed. + +The following configuration will reject any kind of entities from being added to +the catalog: + +```yaml +catalog: + rules: [] +``` diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 4751bd88f7..301d80630c 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -2,6 +2,8 @@ id: descriptor-format title: Descriptor Format of Catalog Entities sidebar_label: YAML File Format +description: Documentation on Descriptor Format of Catalog Entities which +describes the default data shape and semantics of catalog entities --- This section describes the default data shape and semantics of catalog entities. @@ -34,7 +36,7 @@ software catalog API. "annotations": { "backstage.io/managed-by-location": "file:/tmp/component-info.yaml", "example.com/service-discovery": "artistweb", - "circleci.com/project-slug": "gh/example-org/artist-website" + "circleci.com/project-slug": "github/example-org/artist-website" }, "description": "The place to be, for great artists", "etag": "ZjU2MWRkZWUtMmMxZS00YTZiLWFmMWMtOTE1NGNiZDdlYzNk", @@ -42,6 +44,7 @@ software catalog API. "labels": { "system": "public-websites" }, + "tags": ["java"], "name": "artist-web", "uid": "2152f463-549d-4d8d-a94d-ce2b7676c6e2" }, @@ -65,7 +68,9 @@ metadata: system: public-websites annotations: example.com/service-discovery: artistweb - circleci.com/project-slug: gh/example-org/artist-website + circleci.com/project-slug: github/example-org/artist-website + tags: + - java spec: type: website lifecycle: production @@ -232,6 +237,23 @@ The `backstage.io/` prefix is reserved for use by Backstage core components. Values can be of any length, but are limited to being strings. +There is a list of [well-known annotations](well-known-annotations.md), but +anybody is free to add more annotations as they see fit. + +### `tags` [optional] + +A list of single-valued strings, for example to classify catalog entities in +various ways. This is different to the labels in metadata, as labels are +key-value pairs. + +The values are user defined, for example the programming language used for the +component, like `java` or `go`. + +This field is optional, and currently has no special semantics. + +Each tag must be sequences of `[a-zA-Z0-9]` separated by `-`, at most 63 +characters in total. + ## Kind: Component Describes the following entity kind: @@ -273,7 +295,7 @@ Exactly equal to `backstage.io/v1alpha1` and `Component`, respectively. The type of component as a string, e.g. `website`. This field is required. -The software catalog accepts any type value, but an organisation should take +The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface @@ -287,9 +309,9 @@ The current set of well-known and common values for this field is: ### `spec.lifecycle` [required] -The lifecyle state of the component, e.g. `production`. This field is required. +The lifecycle state of the component, e.g. `production`. This field is required. -The software catalog accepts any lifecycle value, but an organisation should +The software catalog accepts any lifecycle value, but an organization should take great care to establish a proper taxonomy for these. The current set of well-known and common values for this field is: @@ -353,8 +375,8 @@ metadata: description: Next.js application skeleton for creating isomorphic web applications. tags: - - Recommended - - React + - recommended + - react spec: owner: web@example.com templater: cookiecutter @@ -401,7 +423,7 @@ potentially search and group templates by these tags. The type of component as a string, e.g. `website`. This field is optional but recommended. -The software catalog accepts any type value, but an organisation should take +The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface @@ -451,6 +473,7 @@ Describes the following entity kind: An API describes an interface that can be exposed by a component. The API can be defined in different formats, like [OpenAPI](https://swagger.io/specification/), [AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/), +[GraphQL](https://graphql.org/learn/schema/), [gRPC](https://developers.google.com/protocol-buffers), or other formats. Descriptor files for this kind may look as follows. @@ -463,6 +486,8 @@ metadata: description: Retrieve artist details spec: type: openapi + lifecycle: production + owner: artist-relations@example.com definition: | openapi: "3.0.0" info: @@ -491,7 +516,7 @@ Exactly equal to `backstage.io/v1alpha1` and `API`, respectively. The type of the API definition as a string, e.g. `openapi`. This field is required. -The software catalog accepts any type value, but an organisation should take +The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, an OpenAPI type API may be displayed using an OpenAPI viewer tooling in @@ -507,6 +532,41 @@ The current set of well-known and common values for this field is: [Protocol Buffers](https://developers.google.com/protocol-buffers) to use with [gRPC](https://grpc.io/). +### `spec.lifecycle` [required] + +The lifecycle state of the API, e.g. `production`. This field is required. + +The software catalog accepts any lifecycle value, but an organization should +take great care to establish a proper taxonomy for these. + +The current set of well-known and common values for this field is: + +- `experimental` - an experiment or early, non-production API, signaling that + users may not prefer to consume it over other more established APIs, or that + there are low or no reliability guarantees +- `production` - an established, owned, maintained API +- `deprecated` - an API that is at the end of its lifecycle, and may disappear + at a later point in time + +### `spec.owner` [required] + +The owner of the API, e.g. `artist-relations@example.com`. This field is +required. + +In Backstage, the owner of an API is the singular entity (commonly a team) that +bears ultimate responsibility for the API, and has the authority and capability +to develop and maintain it. They will be the point of contact if something goes +wrong, or if features are to be requested. The main purpose of this field is for +display purposes in Backstage, so that people looking at catalog items can get +an understanding of to whom this API belongs. It is not to be used by automated +processes to for example assign authorization in runtime systems. There may be +others that also develop or otherwise touch the API, but there will always be +one ultimate owner. + +Apart from being a string, the software catalog leaves the format of this field +open to implementers to choose. Most commonly, it is set to the ID or email of a +group of people in an organizational structure. + ### `spec.definition` [required] The definition of the API, based on the format defined by `spec.type`. This diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md index ce5ea03b26..4e8a357ea7 100644 --- a/docs/features/software-catalog/extending-the-model.md +++ b/docs/features/software-catalog/extending-the-model.md @@ -1,6 +1,7 @@ --- id: extending-the-model title: Extending the model +description: Documentation on Extending the model --- Backstage natively supports tracking of the following component @@ -12,7 +13,7 @@ Backstage natively supports tracking of the following component - Documentation - Other -![](bsc-extend.png) +![](../../assets/software-catalog/bsc-extend.png) Since these types are likely not the only kind of software you will want to track in Backstage, it is possible to @@ -31,9 +32,10 @@ catalog. It might be tempting to put software that doesn't fit into any of the existing types into Other. There are a few reasons why we advice against this; firstly, we have found that it is preferred to match the conceptual model that your -engineers have when describing your sofware. Secondly, Backstage helps your -engineers manage their software by integrating the infratrucure tooling through -plugins. Different plugins are used for managing different types of components. +engineers have when describing your software. Secondly, Backstage helps your +engineers manage their software by integrating the infrastructure tooling +through plugins. Different plugins are used for managing different types of +components. For example, the [Lighthouse plugin](https://github.com/spotify/backstage/tree/master/plugins/lighthouse) diff --git a/docs/features/software-catalog/external-integrations.md b/docs/features/software-catalog/external-integrations.md index 7ca869cdae..00a9d95fc2 100644 --- a/docs/features/software-catalog/external-integrations.md +++ b/docs/features/software-catalog/external-integrations.md @@ -1,6 +1,8 @@ --- id: external-integrations title: External integrations +description: Documentation on External integrations to integrate systems +with Backstage --- Backstage natively supports storing software components in diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md index fe107d1f59..49a490d78b 100644 --- a/docs/features/software-catalog/index.md +++ b/docs/features/software-catalog/index.md @@ -1,6 +1,9 @@ --- id: software-catalog-overview title: Backstage Service Catalog (alpha) +sidebar_label: Overview +description: The Backstage Service Catalog — actually, a software catalog, since +it includes more than just services --- ## What is a Service Catalog? @@ -47,25 +50,25 @@ There are 3 ways to add components to the catalog: 1. Manually register components 2. Creating new components through Backstage -3. Integrating with and [external source](external-integrations.md) +3. Integrating with an [external source](external-integrations.md) ### Manually register components Users can register new components by going to `/create` and clicking the **REGISTER EXISTING COMPONENT** button: -![](bsc-register-1.png) +![](../../assets/software-catalog/bsc-register-1.png) Backstage expects the full URL to the YAML in your source control. Example: -``` +```bash https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml ``` _More examples can be found [here](https://github.com/spotify/backstage/tree/master/packages/catalog-model/examples)._ -![](bsc-register-2.png) +![](../../assets/software-catalog/bsc-register-2.png) It is important to note that any kind of software can be registered in Backstage. Even if the software is not maintained by your company (SaaS @@ -78,12 +81,28 @@ All software created through the [Backstage Software Templates](../software-templates/index.md) are automatically registered in the catalog. +### Static catalog configuration + +In addition to manually registering components, it is also possible to register +components though [static configuration](../../conf/index.md). For example, the +above example can be added using the following configuration: + +```yaml +catalog: + locations: + - type: github + target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml +``` + +More information about catalog configuration can be found +[here](configuration.md). + ### Updating component metadata Teams owning the components are responsible for maintaining the metadata about them, and do so using their normal Git workflow. -![](bsc-edit.png) +![](../../assets/software-catalog/bsc-edit.png) Once the change has been merged, Backstage will automatically show the updated metadata in the service catalog after a short while. @@ -92,25 +111,25 @@ metadata in the service catalog after a short while. By default the service catalog shows components owned by the team of the logged in user. But you can also switch to _All_ to see all the components across your -companie's software ecosystem. Basic inline _search_ and _column filtering_ -makes it easy to browse a big set of components. +company's software ecosystem. Basic inline _search_ and _column filtering_ makes +it easy to browse a big set of components. -![](bsc-search.png) +![](../../assets/software-catalog/bsc-search.png) ## Starring components For easy and quick access to components you visit frequently, Backstage supports _starring_ of components: -![](bsc-starred.png) +![](../../assets/software-catalog/bsc-starred.png) ## Integrated tooling through plugins -The service catalog is a great way to organise the infrastructure tools you use +The service catalog is a great way to organize the infrastructure tools you use to manage the software. This is how Backstage creates one developer portal for all your tools. Rather than asking teams to jump between different -infrastructure UI’s (and incurring additional cognitive overhead each time they -make a context switch), most of these tools can be organised around the entities +infrastructure UIs (and incurring additional cognitive overhead each time they +make a context switch), most of these tools can be organized around the entities in the catalog. ![tools](https://backstage.io/blog/assets/20-05-20/tabs.png) diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index 397abcc6b0..a825020eb6 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -1,4 +1,8 @@ -# Installing in your Backstage App +--- +id: installation +title: Installing in your Backstage App +description: Documentation on How to install Backstage Plugin +--- The catalog plugin comes in two packages, `@backstage/plugin-catalog` and `@backstage/plugin-catalog-backend`. Each has their own installation steps, diff --git a/docs/features/software-catalog/system-model.md b/docs/features/software-catalog/system-model.md index 4b2884e097..44dac13d65 100644 --- a/docs/features/software-catalog/system-model.md +++ b/docs/features/software-catalog/system-model.md @@ -1,57 +1,29 @@ --- id: system-model title: System Model +description: Documentation on System Model --- -We believe that a strong shared understanding and terminology around systems, -software and resources leads to a better Backstage experience. +We believe that a strong shared understanding and terminology around software +and resources leads to a better Backstage experience. _This description originates from [this RFC](https://github.com/spotify/backstage/issues/390). Note that some of the concepts are not yet supported in Backstage._ -## Concepts +## Core Entities -We model our technology using these five concepts (further explained below): +We model software in the Backstage catalogue using these three core entities +(further explained below): + +- **Components** are individual pieces of software + +- **APIs** are the boundaries between different components -- **Domains** are a high-level grouping of systems -- **Systems** encapsulate the implementation of APIs -- **APIs** are the boundaries between different components and systems -- **Components** are pieces of software - **Resources** are physical or virtual infrastructure needed to operate a - system + component -![Software Ecosystem Model_ Public Github version](https://user-images.githubusercontent.com/24575/77633084-39bcde80-6f4f-11ea-8251-f8df561a3652.png) - -### Domain - -While systems are the basic level of encapsulation for resources, components and -APIs, it is often useful to group a collection of systems that share -terminology, domain models, business purpose, or documentation, i.e. they form a -bounded context. - -For example, it would make sense if the different systems in the “Payments” -domain would come with some documentation on how to accept payments for a new -product or use-case, share the same entity types in their APIs, and integrate -well with each other. - -### System - -With increasing complexity in software, we believe that systems form an -important abstraction level to help us reason about software ecosystems. Systems -are a useful concept in that they allow us to ignore the implementation details -of a certain functionality for consumers, while allowing the owning team to make -changes as they see fit (leading to low coupling). - -A system, in this sense, is a collection of resources and components that -exposes one or several APIs. Components and resources in a system are typically -owned by the same team and are expected to co-evolve. As such, systems usually -consist of at most a handful of components. - -For example, a playlist management system might encapsulate a backend service to -update playlists, a backend service to query them, and a database to store them. -It could expose an RPC API, a daily snapshots dataset, and an event stream of -playlist updates. +![](../../assets/software-catalog/software-model-core-entities.png) ### Component @@ -60,34 +32,80 @@ backend service or data pipeline (list not exhaustive). A component can be tracked in source control, or use some existing open source or commercial software. -A component can implement APIs for other components to consume. It might depend -on the resources of the system it belongs to, and APIs from other components or -other systems. All other aspects of the component, e.g. any code dependencies, -must be encapsulated. +A component can implement APIs for other components to consume. In turn it might +depend on APIs implemented by other components, or resources that are attached +to it at runtime. ### API -We believe APIs form an important (maybe the most important) abstraction that -allows large software ecosystems to scale. Thus, APIs are a first class citizen -in the Backstage model and the primary way to discover existing functionality in -the ecosystem. +APIs form an important (maybe the most important) abstraction that allows large +software ecosystems to scale. Thus, APIs are a first class citizen in the +Backstage model and the primary way to discover existing functionality in the +ecosystem. -APIs are implemented by components and form boundaries between components and -systems. They might be defined using an RPC IDL (eg Protobuf, GraphQL, ...), a -data schema (eg Avro, TFRecord, ...), or as code interfaces. In any case, APIs -exposed by components need to be in a known machine-readable format so we can -build further tooling and analysis on top. +APIs are implemented by components and form boundaries between components. They +might be defined using an RPC IDL (eg Protobuf, GraphQL, ...), a data schema (eg +Avro, TFRecord, ...), or as code interfaces. In any case, APIs exposed by +components need to be in a known machine-readable format so we can build further +tooling and analysis on top. -Some APIs might be exposed by the system, making them available for any other -Spotify component to consume. Those public APIs must be documented and humanly -discoverable in Backstage. +APIs have a visibility: they are either public (making them available for any +other component to consume), restricted (only available to a whitelisted set of +consumers), or private (only available within their system). As public APIs are +going to be the primary way interaction between components, Backstage supports +documenting, indexing and searching all APIs so we can browse them as +developers. ### Resource -Resources are the infrastructure a system needs to operate, like BigTable -databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with -components and systems will better allow us to visualize resource footprint, and -create tooling around them. +Resources are the infrastructure a component needs to operate at runtime, like +BigTable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together +with components and systems will better allow us to visualize resource +footprint, and create tooling around them. + +## Ecosystem Modeling + +A large catalogue of components, APIs and resources can be highly granular and +hard to understand as a whole. It might thus be convenient to further categorize +these entities using the following (optional) concepts: + +- **Systems** are a collection of entities that cooperate to perform some + function +- **Domains** relate entities and systems to part of the business + +### System + +With increasing complexity in software, systems form an important abstraction +level to help us reason about software ecosystems. Systems are a useful concept +in that they allow us to ignore the implementation details of a certain +functionality for consumers, while allowing the owning team to make changes as +they see fit (leading to low coupling). + +A system, in this sense, is a collection of resources and components that +exposes one or several public APIs. The main benefit of modelling a system is +that it hides its resources and private APIs between the components for any +consumers. This means that as the owner, you can evolve the implementation, in +terms of components and resources, without your consumers being able to notice. +Typically, a system will consist of at most a handful of components (see Domain +for a grouping of systems). + +For example, a playlist management system might encapsulate a backend service to +update playlists, a backend service to query them, and a database to store them. +It could expose an RPC API, a daily snapshots dataset, and an event stream of +playlist updates. + +### Domain + +While systems are the basic level of encapsulation for related entities, it is +often useful to group a collection of systems that share terminology, domain +models, metrics, KPIs, business purpose, or documentation, i.e. they form a +bounded context. + +For example, it would make sense if the different systems in the “Payments” +domain would come with some documentation on how to accept payments for a new +product or use-case, share the same entity types in their APIs, and integrate +well with each other. Other domains could be “Content Ingestion”, “Ads” or +“Search”. ## Current status diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md new file mode 100644 index 0000000000..ba5b491e07 --- /dev/null +++ b/docs/features/software-catalog/well-known-annotations.md @@ -0,0 +1,137 @@ +--- +id: well-known-annotations +title: Well-known Annotations on Catalog Entities +sidebar_label: Well-known Annotations +description: Documentation on lists a number of well known Annotations, that +have defined semantics. They can be attached to catalog entities and consumed +by plugins as needed +--- + +This section lists a number of well known +[annotations](descriptor-format.md#annotations-optional), that have defined +semantics. They can be attached to catalog entities and consumed by plugins as +needed. + +## Annotations + +This is a (non-exhaustive) list of annotations that are known to be in active +use. + +### backstage.io/managed-by-location + +```yaml +# Example: +metadata: + annotations: + backstage.io/managed-by-location: github:http://github.com/spotify/backstage/catalog-info.yaml +``` + +The value of this annotation is a so called location reference string, that +points to the source from which the entity was originally fetched. This +annotation is added automatically by the catalog as it fetches the data from a +registered location, and is not meant to normally be written by humans. The +annotation may point to any type of generic location that the catalog supports, +so it cannot be relied on to always be specifically of type `github`, nor that +it even represents a single file. Note also that a single location can be the +source of many entities, so it represents a many-to-one relationship. + +The format of the value is `:`. Note that the target may also +contain colons, so it is not advisable to naively split the value on `:` and +expecting a two-item array out of it. The format of the target part is +type-dependent and could conceivably even be an empty string, but the separator +colon is always present. + +### backstage.io/techdocs-ref + +```yaml +# Example: +metadata: + annotations: + backstage.io/techdocs-ref: github:https://github.com/spotify/backstage.git +``` + +The value of this annotation is a location reference string (see above). If this +annotation is specified, it is expected to point to a repository that the +TechDocs system can read and generate docs from. + +### jenkins.io/github-folder + +```yaml +# Example: +metadata: + annotations: + jenkins.io/github-folder: folder-name/job-name +``` + +The value of this annotation is the path to a job on Jenkins, that builds this +entity. + +Specifying this annotation may enable Jenkins related features in Backstage for +that entity. + +### github.com/project-slug + +```yaml +# Example: +metadata: + annotations: + github.com/project-slug: spotify/backstage +``` + +The value of this annotation is the so-called slug that identifies a project on +[GitHub](https://github.com) that is related to this entity. It is on the format +`/`, and is the same as can be seen in the URL location +bar of the browser when viewing that project. + +Specifying this annotation will enable GitHub related features in Backstage for +that entity. + +### sentry.io/project-slug + +```yaml +# Example: +metadata: + annotations: + sentry.io/project-slug: pump-station +``` + +The value of this annotation is the so-called slug (or alternatively, the ID) of +a [Sentry](https://sentry.io) project within your organization. The organization +slug is currently not configurable on a per-entity basis, but is assumed to be +the same for all entities in the catalog. + +Specifying this annotation may enable Sentry related features in Backstage for +that entity. + +### rollbar.com/project-slug + +```yaml +# Example: +metadata: + annotations: + rollbar.com/project-slug: spotify/pump-station +``` + +The value of this annotation is the so-called slug (or alternatively, the ID) of +a [Rollbar](https://rollbar.com) project within your organization. The value can +be the format of `[organization]/[project-slug]` or just `[project-slug]`. When +the organization slug is omitted the `app-config.yaml` will be used as a +fallback (`rollbar.organization` followed by `organization.name`). + +Specifying this annotation may enable Rollbar related features in Backstage for +that entity. + +## Deprecated Annotations + +The following annotations are deprecated, and only listed here to aid in +migrating away from them. + +### backstage.io/github-actions-id + +This annotation was used for a while to enable the GitHub Actions feature. This +is now instead using the [github.com/project-slug](#github-com-project-slug) +annotation, with the same value format. + +## Links + +- [Descriptor Format: annotations](descriptor-format.md#annotations-optional) diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md index 2ec4bdd6f3..fad5c68e41 100644 --- a/docs/features/software-templates/adding-templates.md +++ b/docs/features/software-templates/adding-templates.md @@ -1,6 +1,7 @@ --- id: adding-templates title: Adding your own Templates +description: Documentation on Adding your own Templates --- Templates are stored in the **Service Catalog** under a kind `Template`. The @@ -22,8 +23,8 @@ metadata: Next.js application skeleton for creating isomorphic web applications. # some tags to display in the frontend tags: - - Recommended - - React + - recommended + - react spec: # which templater key to use in the templaters builder templater: cookiecutter @@ -54,11 +55,34 @@ contains more information about the required fields. Once we have a `template.yaml` ready, we can then add it to the service catalog for use by the scaffolder. -Currently the catalog supports loading definitions from Github + Local Files. To +Currently the catalog supports loading definitions from GitHub + Local Files. To load from other places, not only will there need to be another preparer, but the support to load the location will also need to be added to the Catalog. -For loading from a file the following command should work when the backend is +You can add the template files to the catalog through +[static location configuration](../software-catalog/configuration.md#static-location-configuration), +for example + +```yaml +catalog: + locations: + - type: github + target: https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml + rules: + - allow: [Template] +``` + +Templates can also be added by posting the to the catalog directly. Note that if +you're doing this, you need to configure the catalog to allow template entities +to be ingested from any source, for example: + +```yaml +catalog: + rules: + - allow: [Component, API, Template] +``` + +For loading from a file, the following command should work when the backend is running: ```sh @@ -69,7 +93,7 @@ curl \ --data-raw "{\"type\": \"file\", \"target\": \"${YOUR PATH HERE}/template.yaml\"}" ``` -If loading from a git location, you can run the following +If loading from a Git location, you can run the following ```sh curl \ @@ -82,13 +106,6 @@ curl \ This should then have added the catalog, and also should now be listed under the create page at http://localhost:3000/create. -Alternatively, if you want to get setup with some mock templates that are -already provided for you, you can run the following to load those templates: - -``` -yarn lerna run mock-data -``` - The `type` field which is chosen in the request to add the `template.yaml` to the Service Catalog here, will be come the `PreparerKey` which will be used to select the `Preparer` when creating a job. diff --git a/docs/features/software-templates/extending/create-your-own-preparer.md b/docs/features/software-templates/extending/create-your-own-preparer.md index 739c516658..14c75b0ffb 100644 --- a/docs/features/software-templates/extending/create-your-own-preparer.md +++ b/docs/features/software-templates/extending/create-your-own-preparer.md @@ -1,6 +1,7 @@ --- id: extending-preparer title: Create your own Preparer +description: Documentation on Creating your own Preparer --- Preparers are responsible for reading the location of the definition of a @@ -54,7 +55,7 @@ The `protocol` is set on the when added to the service catalog. You can see more about this `PreparerKey` here in [Register your own template](../adding-templates.md) -**note:** Currently the catalog supports loading definitions from Github + Local +**note:** Currently the catalog supports loading definitions from GitHub + Local Files, which translate into the two `PreparerKeys` `file` and `github`. To load from other places, not only will there need to be another preparer, but the support to load the location will also need to be added to the Catalog. diff --git a/docs/features/software-templates/extending/create-your-own-publisher.md b/docs/features/software-templates/extending/create-your-own-publisher.md index 51869dc3ad..25d7bcf676 100644 --- a/docs/features/software-templates/extending/create-your-own-publisher.md +++ b/docs/features/software-templates/extending/create-your-own-publisher.md @@ -1,24 +1,25 @@ --- id: extending-publisher title: Create your own Publisher +description: Documentation on Creating your own Publisher --- Publishers are responsible for pushing and storing the templated skeleton after the values have been templated by the `Templater`. See [Create your own templater](./create-your-own-templater.md) for more info. -They recieve a directory or location where the templater has sucessfully run on, -and is now ready to store somewhere. They also get given some other options -which are sent from the frontend, such as the `storePath` which is a string of -where the frontend thinks we should save this templated folder. +They receive a directory or location where the templater has sucessfully run and +is now ready to store somewhere. They also are given some other options which +are sent from the frontend, such as the `storePath` which is a string of where +the frontend thinks we should save this templated folder. Currently we provide the following `publishers`: - `github` This publisher is passed through to the `createRouter` function of the -`@spotify/plugin-scaffolder-backend`. Currently only one publisher is supported, -but PR's are always welcome. +`@spotify/plugin-scaffolder-backend`. Currently, only one publisher is +supported, but PR's are always welcome. An full example backend can be found [here](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), diff --git a/docs/features/software-templates/extending/create-your-own-templater.md b/docs/features/software-templates/extending/create-your-own-templater.md index ac05eed3b0..36c362b9c3 100644 --- a/docs/features/software-templates/extending/create-your-own-templater.md +++ b/docs/features/software-templates/extending/create-your-own-templater.md @@ -1,6 +1,7 @@ --- id: extending-templater title: Creating your own Templater +description: Documentation on Creating your own Templater --- Templaters are responsible for taking the directory path for the skeleton @@ -8,14 +9,14 @@ returned by the preparers, and then executing the templating command on top of the file and returning the completed template path. This may or may not be the same directory as the input directory. -They also recieve additional values from the frontend, which can be used to +They also receive additional values from the frontend, which can be used to interpolate into the skeleton files. Currently we provide the following templaters: - `cookiecutter` -This templater is added the `TemplaterBuilder` and then passed into the +This templater is added to the `TemplaterBuilder` and then passed into the `createRouter` function of the `@spotify/plugin-scaffolder-backend` An full example backend can be found @@ -48,7 +49,7 @@ This `TemplaterKey` is used to select the correct templater from the `spec.templater` in the [Template Entity](../../software-catalog/descriptor-format.md#kind-template). -If you wish to add a new templater you'll need to register it with the +If you wish to add a new templater, you'll need to register it with the `TemplaterBuilder`. ### Creating your own Templater to add to the `TemplaterBuilder` @@ -83,10 +84,10 @@ follows: - `dockerClient` - a [dockerode](https://github.com/apocas/dockerode) client to be able to run docker containers. -_note_ currently the templaters that we provide are basically docker action +_note_ Currently the templaters that we provide are basically Docker action containers that are run on top of the skeleton folder. This keeps dependencies to a minimal for running backstage scaffolder, but you don't /have/ to use -docker. You could create your own templater that spins up an EC2 instance and +Docker. You could create your own templater that spins up an EC2 instance and downloads the folder and does everything using an AMI if you want. It's entirely up to you! @@ -116,8 +117,8 @@ metadata: description: Next.js application skeleton for creating isomorphic web applications. tags: - - Recommended - - React + - recommended + - react spec: owner: web@example.com templater: handlebars @@ -138,7 +139,7 @@ spec: description: Description of the component ``` -You see that the `spec.templater` is set as `handlebars`, you'll need to +You see that the `spec.templater` is set as `handlebars`, so you'll need to register this with the `TemplaterBuilder` like so: ```ts diff --git a/docs/features/software-templates/extending/index.md b/docs/features/software-templates/extending/index.md index 21d8befda1..02d27a5725 100644 --- a/docs/features/software-templates/extending/index.md +++ b/docs/features/software-templates/extending/index.md @@ -5,11 +5,11 @@ title: Extending the Scaffolder Welcome. Take a seat. You're at the Scaffolder Documentation. -So - You wanna create stuff inside your company from some prebaked templates? +So, you want to create stuff inside your company from some prebaked templates? You're at the right place. -This guide is gonna take you through how the Scaffolder in Backstage works. -We'll dive into some jargon and run through whats going on in the backend to be +This guide is going to take you through how the Scaffolder in Backstage works. +We'll dive into some jargon and run through what's going on in the backend to be able to create these templates. There's also more guides that you might find useful at the bottom of this document. At it's core, theres 3 simple stages. @@ -25,9 +25,9 @@ scaffolder that you will need to know: 3. Publish Each of these steps can be configured for your own use case, but we provide some -sensible defaults too. +sensible defaults, too. -Lets dive a little deeper into these phases. +Let's dive a little deeper into these phases. ### Glossary and Jargon @@ -38,8 +38,8 @@ the router to pick the correct `Preparer` to run for the `Template` entity. **Templater** - The templater is responsible for actually running the chosen templater on top of the previously returned temporary directory from the -**Preprarer**. We advise making these docker containers as it can keep all -dependencies, for example Cookiecutter, self contained and not a dependency on +**Preprarer**. We advise making these Docker containers as it can keep all +dependencies--for example Cookiecutter--self contained and not a dependency on the host machine. **Publisher** - The publisher is responsible for taking the finished directory, @@ -50,11 +50,11 @@ passed through to the scaffolder backend. ### How it works -The main of the heavy lifting is done in the +Most of the heavy lifting is done in the [router.ts](https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/src/service/router.ts#L93) file in the `scaffolder-backend` plugin. -There are 2 routes defined in the router. `POST /v1/jobs` and +There are two routes defined in the router: `POST /v1/jobs` and `GET /v1/job/:jobId` To create a scaffolding job, a JSON object containing the @@ -78,7 +78,7 @@ additional templating values must be posted as the post body. The values should represent something that is valid with the `schema` part of the [Template Entity](../../software-catalog/descriptor-format.md#kind-template) -Once that has been posted, a job will be setup with different stages. And the +Once that has been posted, a job will be setup with different stages, and the job processor will complete each stage before moving onto the next stage, whilst collecting logs and mutating the running job. diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index d9a86ee7aa..ae121f8029 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -1,12 +1,15 @@ --- id: software-templates-index -title: Software Templates +title: Backstage Software Templates +sidebar_label: Overview +description: The Software Templates part of Backstage is a tool that can help +you create Components inside Backstage --- The Software Templates part of Backstage is a tool that can help you create -Components inside Backstage. It by default has the ability to load skeletons of -code, template in some variables and then publish the template to some location -like GitHub. +Components inside Backstage. By default, it has the ability to load skeletons of +code, template in some variables, and then publish the template to some +locations like GitHub or GitLab.

- create app + create app

Inside that directory, it will generate all the files and folder structure needed for you to run your app. -### Folder structure +### General folder structure + +Below is a simplified layout of the files and folders generated when creating an +app. ``` app -├── README.md +├── app-config.yaml ├── lerna.json ├── package.json -├── prettier.config.js -├── tsconfig.json -├── packages -│ └── app -│ ├── package.json -│ ├── tsconfig.json -│ ├── public -│ │ └── ... -│ └── src -│ ├── App.test.tsx -│ ├── App.tsx -│ ├── index.tsx -│ ├── plugins.ts -│ └── setupTests.ts -└── plugins - └── welcome - ├── README.md - ├── package.json - ├── tsconfig.json - └── src - ├── index.ts - ├── plugin.test.ts - ├── plugin.ts - ├── setupTests.ts - └── components - ├── Timer - │ └── ... - └── WelcomePage - └── ... +└── packages +   ├── app +   └── backend ``` +- **app-config.yaml**: Main configuration file for the app. See + [Configuration](https://backstage.io/docs/conf/) for more information. +- **lerna.json**: Contains information about workspaces and other lerna + configuration needed for the monorepo setup. +- **package.json**: Root package.json for the project. _Note: Be sure that you + don't add any npm dependencies here as they probably should be installed in + the intended workspace rather than in the root._ +- **packages/**: Lerna leaf packages or "workspaces". Everything here is going + to be a separate package, managed by lerna. +- **packages/app/**: An fully functioning Backstage frontend app, that acts as a + good starting point for you to get to know Backstage. +- **packages/backend/**: We include a backend that helps power features such as + [Authentication](https://backstage.io/docs/auth/), + [Software Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview), + [Software Templates](https://backstage.io/docs/features/software-templates/software-templates-index) + and [TechDocs](https://backstage.io/docs/features/techdocs/techdocs-overview) + amongst other things. + ## Run the app When the installation is complete you can open the app folder and start the app. @@ -80,3 +82,13 @@ yarn start _When `yarn start` is ready it should open up a browser window displaying your app, if not you can navigate to `http://localhost:3000`._ + +In most cases you will want to start the backend as well, as it is required for +the catalog to work, along with many other plugins. + +To start the backend, open a separate terminal session and run the following in +the root directory: + +```bash +yarn workspace backend start +``` diff --git a/docs/getting-started/create-app_output.png b/docs/getting-started/create-app_output.png deleted file mode 100644 index 52dcc13097..0000000000 Binary files a/docs/getting-started/create-app_output.png and /dev/null differ diff --git a/docs/getting-started/deployment-k8s.md b/docs/getting-started/deployment-k8s.md index 2a184779e2..ddcbd290c6 100644 --- a/docs/getting-started/deployment-k8s.md +++ b/docs/getting-started/deployment-k8s.md @@ -1,6 +1,7 @@ --- id: deployment-k8s title: Kubernetes +description: Documentation on Kubernetes and K8s Deployment --- Coming soon! diff --git a/docs/getting-started/deployment-other.md b/docs/getting-started/deployment-other.md index 3b7796f8fe..2cd9708e2a 100644 --- a/docs/getting-started/deployment-other.md +++ b/docs/getting-started/deployment-other.md @@ -1,6 +1,7 @@ --- id: deployment-other title: Other +description: Documentation on different ways of Deployment --- ## Deploying Locally @@ -10,21 +11,20 @@ title: Other Run the following commands if you have Docker environment ```bash +$ yarn install $ yarn docker-build -$ docker run --rm -it -p 80:80 spotify/backstage +$ docker run --rm -it -p 7000:7000 -e NODE_ENV=development example-backend:latest ``` Then open http://localhost/ on your browser. ### Running with `docker-compose` -Run the following commands if you have docker and docker-compose for a full -example, with the example backend also deployed. +There is also a `docker-compose.yaml` that you can use to replace the previous +`docker run` command: ```bash -$ yarn docker-build:all +$ yarn install +$ yarn docker-build $ docker-compose up ``` - -Then open http://localhost:3000 on your browser to see the example app with an -example backend. diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index 6a3a2cc7a2..154a959f85 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -1,6 +1,8 @@ --- id: development-environment title: Development Environment +description: Documentation on how to get set up for doing development on +the Backstage repository --- This section describes how to get set up for doing development on the Backstage @@ -65,6 +67,7 @@ yarn storybook # Start local storybook, useful for working on components in @bac yarn workspace @backstage/plugin-welcome start # Serve welcome plugin only, also supports --check yarn tsc # Run typecheck, use --watch for watch mode +yarn tsc:full # Run full type checking, for example without skipLibCheck, use in CI yarn build # Build published versions of packages, depends on tsc @@ -85,7 +88,3 @@ yarn create-plugin # Create a new plugin > See > [package.json](https://github.com/spotify/backstage/blob/master/package.json) > for other yarn commands/options. - -[Next Step - Create a Backstage plugin](../plugins/create-a-plugin.md) - -[Back to Docs](../README.md) diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index bfd3c70913..a9ac770f90 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -1,70 +1,51 @@ --- id: index -title: Running Backstage Locally +title: Getting Started +description: Documentation on How to get started with Backstage --- -To get up and running with a local Backstage to evaluate it, let's clone it off -of GitHub and run an initial build. First make sure that you have at least node -version 12 installed locally. +There are two different ways to get started with Backstage, either by creating a +standalone app, or by cloning this repo. Which method you use depends on what +you're planning to do. + +Creating a standalone instance makes it simpler to customize the application for +your needs whilst staying up to date with the project. You will also depend on +`@backstage` packages from NPM, making the project much smaller. This is the +recommended approach if you want to kick the tyres of Backstage or setup your +own instance. + +On the other hand, if you want to contribute plugins or to the project in +general, it's easier to fork and clone this project. That will let you stay up +to date with the latest changes, and gives you an easier path to make Pull +Requests towards this repo. + +### Creating a Standalone App + +Backstage provides the `@backstage/create-app` package to scaffold standalone +instances of Backstage. You will need to have +[NodeJS](https://nodejs.org/en/download/) Active LTS Release installed +(currently v12), and [yarn](https://classic.yarnpkg.com/en/docs/install). You +will also need to have [Docker](https://docs.docker.com/engine/install/) +installed to use some features like Software Templates and TechDocs. + +Using `npx` you can then run the following to create an app in a chosen +subdirectory of your current working directory: ```bash -# Start from your local development folder -git clone git@github.com:spotify/backstage.git -cd backstage - -# Fetch our dependencies and run an initial build -yarn install -yarn tsc -yarn build +npx @backstage/create-app ``` -Phew! Now you have a local repository that's ready to run and to add any open -source contributions into. +You will be taken through a wizard to create your app, and the output should +look something like this. You can read more about this process +[here](https://backstage.io/docs/getting-started/create-an-app). -We are now going to launch two things: an example Backstage frontend app, and an -example Backstage backend that the frontend talks to. You are going to need two -terminal windows, both starting from the Backstage project root. +### Contributing to Backstage -In the first window, run +You can read more in our +[CONTRIBUTING](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md) +guide, which can help you get setup with a Backstage development environment. -```bash -cd packages/backend -yarn start -``` +### Next steps -That starts up a backend instance on port 7000. - -In the other window, we will first populate the catalog with some nice mock data -to look at, and then launch the frontend. These commands are run from the -project root, not inside the backend directory. - -```bash -yarn lerna run mock-data -yarn start -``` - -That starts up the frontend on port 3000, and should automatically open a -browser window showing it. - -Congratulations! That should be it. Let us know how it went -[on discord](https://discord.gg/EBHEGzX), file issues for any -[feature](https://github.com/spotify/backstage/issues/new?labels=help+wanted&template=feature_template.md) -or -[plugin suggestions](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME), -or -[bugs](https://github.com/spotify/backstage/issues/new?labels=bug&template=bug_template.md) -you have, and feel free to -[contribute](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md)! - -## Creating a Plugin - -The value of Backstage grows with every new plugin that gets added. Here is a -collection of tutorials that will guide you through setting up and extending an -instance of Backstage with your own plugins. - -- [Development Environment](development-environment.md) -- [Create a Backstage Plugin](../plugins/create-a-plugin.md) -- [Structure of a Plugin](../plugins/structure-of-a-plugin.md) -- [Utility APIs](../api/utility-apis.md) - -[Back to Docs](../README.md) +Take a look at the [Running Backstage Locally](./running-backstage-locally.md) +guide to learn how to set up Backstage, and how to develop on the platform. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index a4f91b6e95..3b11d2b8e1 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,6 +1,7 @@ --- id: installation title: Installation +description: Documentation on Installation --- Coming soon! diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md new file mode 100644 index 0000000000..a0cf10998e --- /dev/null +++ b/docs/getting-started/running-backstage-locally.md @@ -0,0 +1,110 @@ +--- +id: running-backstage-locally +title: Running Backstage Locally +description: Documentation on How to run Backstage Locally +--- + +## Prerequisites + +- Node.js + +First make sure you are using NodeJS with an Active LTS Release, currently v12. +This is made easy with a version manager such as +[nvm](https://github.com/nvm-sh/nvm) which allows for version switching. + +```bash +# Installing a new version +nvm install 12 +> Downloading and installing node v12.18.3... +> Now using node v12.18.3 (npm v6.14.6) + +# Checking your version +node --version +> v12.18.3 +``` + +- yarn + +Please refer to the +[installation instructions for yarn](https://classic.yarnpkg.com/en/docs/install/). + +- Docker + +We use Docker for few of our core features. So, you will need Docker installed +locally to use features like Software Templates and TechDocs. Please refer to +the +[installation instructions for Docker](https://docs.docker.com/engine/install/). + +## Clone and Build + +To get up and running with a local Backstage to evaluate it, let's clone it off +of GitHub and run an initial build. + +```bash +# Start from your local development folder +git clone git@github.com:spotify/backstage.git +cd backstage + +# Fetch our dependencies and run an initial build +yarn install +yarn tsc +yarn build +``` + +Phew! Now you have a local repository that's ready to run and to add any open +source contributions into. + +We are now going to launch two things: an example Backstage frontend app, and an +example Backstage backend that the frontend talks to. You are going to need two +terminal windows, both starting from the Backstage project root. + +In the first window, run + +```bash +cd packages/backend +yarn start +``` + +That starts up a backend instance on port 7000. + +In the other window, we will then launch the frontend. This command is run from +the project root, not inside the backend directory. + +```bash +yarn start +``` + +That starts up the frontend on port 3000, and should automatically open a +browser window showing it. + +## Authentication + +When Backstage starts, you can choose to enter as a Guest user and start +exploring. + +But you can also set up any of the available authentication methods. The easiest +option will be GitHub. To setup GitHub authentication in Backstage, see +[these instructions](https://github.com/spotify/backstage/tree/master/plugins/auth-backend#github). + +--- + +Congratulations! That should be it. Let us know how it went +[on discord](https://discord.gg/EBHEGzX), file issues for any +[feature](https://github.com/spotify/backstage/issues/new?labels=help+wanted&template=feature_template.md) +or +[plugin suggestions](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME), +or +[bugs](https://github.com/spotify/backstage/issues/new?labels=bug&template=bug_template.md) +you have, and feel free to +[contribute](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md)! + +## Creating a Plugin + +The value of Backstage grows with every new plugin that gets added. Here is a +collection of tutorials that will guide you through setting up and extending an +instance of Backstage with your own plugins. + +- [Development Environment](development-environment.md) +- [Create a Backstage Plugin](../plugins/create-a-plugin.md) +- [Structure of a Plugin](../plugins/structure-of-a-plugin.md) +- [Utility APIs](../api/utility-apis.md) diff --git a/docs/overview/adopting.md b/docs/overview/adopting.md new file mode 100644 index 0000000000..422adfb288 --- /dev/null +++ b/docs/overview/adopting.md @@ -0,0 +1,162 @@ +--- +id: adopting +title: Strategies for adopting +description: Documentation on some general best practices that have been key +to Backstage's success inside Spotify +--- + +This document outlines some general best practices that have been key to +Backstage's success inside Spotify. Every organization is different and some of +these learnings will therefore not be applicable for your company. We are hoping +that this can become a living document, and strongly encourage you to contribute +back whatever learnings you gather while adopting Backstage inside your company. + +## Organizational setup + +The true value of Backstage is unlocked when it becomes _THE_ developer portal +at your company. As such it is important to recognize that you will need a +central team that owns your Backstage deployment and treats it like a product. + +This team will have **four** primary objectives: + +1. Maintain and operate your deployment of Backstage. This includes customer + support, infrastructure, CI/CD and, as your Backstage product grows, on-call + support. + +2. Drive adoption of customers (developers at your company). + +3. Work with senior tech leadership and architects to ensure your organizations + best practices for software development are encoded into a set of + [Software Templates](../features/software-templates/index.md). + +4. Evangelize Backstage as a central platform towards other + infrastructure/platform teams. + +## Internal evangelization + +The last objective deserves more attention, since it is the least obvious, but +also the most critical to successfully creating a consolidated platform. When +done right, Backstage acts as a "platform of platforms" or marketplace between +infra/platform teams and end-users: + +![pop](../assets/pop.png) + +While anyone at your company can contribute to the platform, the vast majority +of work will be done by teams that also has internal engineers as their +customers. The central team should treat these _contributing teams_ as customers +of the platform as well. + +These teams should be able to autonomously deliver value directly to their +customers. This is done primarily by building [plugins](../plugins/index.md). +Contributing teams should themselves treat their plugins as, or part of, the +products they maintain. + +> Case study: Inside Spotify we have a team that owns our CI platform. They +> don't only maintain the pipelines and build servers, but also expose their +> product in Backstage through a plugin. Since they also +> [maintain their own API](../plugins/call-existing-api.md), they can improve +> their product by iterating on API and UI in lockstep. Because the plugin +> follows our [platform design guidelines](../dls/design.md) their customers get +> a CI experience that is consistent with other tools on the platform (and users +> don't have to become experts in Jenkins). + +### Tactics + +Example of tactics we have used to evangelize Backstage internally: + +- Arrange "Lunch & Learns" and seminars. Frequently offer teams interested in + Backstage development to come to a seminar where you show, for example, how to + build a plugin from scratch. + +- Embedding. As contributing teams start development of their first plugin it is + often very appreciated to have one person from the central team come over and + "embed" for a Sprint or two. + +- Hack days. Backstage-focused Hackathons or hack days is a fun way to get + people into plugin development. + +- Show & tell meetings. In order to build an internal community around Backstage + we have quarterly meetings where anyone working on Backstage is invited to + present their work. This is a not only a great way to get early feedback, but + also helps coordination between teams that are building overlapping + experiences. + +- Provide metrics. Add instrumentation to your Backstage deployment and make + metrics available to contributing teams. At Spotify we have even gone so far + as sending out weekly digest email showing how usage metrics have changed for + individual plugins. + +- Pro-actively identify new plugins. Reach out to teams that own internal UIs or + platforms that you think would make sense to consolidate into Backstage. + +## KPIs and metrics + +These are some of the metrics that you can use to verify if Backstage has a +successful impact on your software development process: + +- **Onboarding time** Time until new engineers are productive. At Spotify we + measure this as the time until the employee has merged their 10th PR (this + metric was down 55% two years after deploying Backstage). Even though you may + not be onboarding engineers at a rapid pace, this metric is a great proxy for + the overall complexity of your ecosystem. Reducing it will therefore benefit + your whole engineering organization, not just new joiners. + +- **Number of merges per developer/day** Less time spent jumping between + different tools and looking for information means more time to focus on + shipping code. A second level of bottlenecks can be identified if you + categorize contributions by domain (services, web, data, etc). + +- **Deploys to production** Cousin to the metric above: How many times does an + engineer push changes into production. + +- **MTTR** With clear ownership of all the pieces in your micro services + ecosystem and all tools integrated into one place, Backstage makes it quicker + for teams to find the root cause of failures, and fix them. + +- **Context switching** Reducing context switching can help engineers stay in + the "zone". We measure the number of different tools an engineer have to + interact with in order to get a certain job done (e.g. push a change, follow + it into production and validate it did not break anything). + +- **T-shapedness** A + [T-shaped](https://medium.com/@jchyip/why-t-shaped-people-e8706198e437) + engineer is someone that is able to contribute to different domains of + engineering. Teams with T-shaped people have fewer bottlenecks and can + therefore deliver more consistently. Backstage makes it easier to be T-shaped + since tools and infrastructure is consistent between domains, and information + is available centrally. + +- **eNPS** Surveys asking about how productive people feel, how easy it is to + find information and overall satisfaction with internal tools. + +- **Fragmentation** _(Experimental)_ Backstage + [Software Templates](../features/software-templates/index.md) helps drive + standardization in your software ecosystem. By measuring the variance in + technology between different software components it is possible to get a sense + of the overall fragmentation in your ecosystem. Examples could include: + framework versions, languages, deployment methods and various code quality + measurements. + +Additionally, these proxy metrics can be used to validate the success of +Backstage as _the_ platform: + +- Nr of teams that have contributed at least one plugin (currently 63 inside + Spotify) + +- Nr of total plugins (currently 135 inside Spotify) + +- % of contributions coming from outside the central Backstage team (currently + 85% inside Spotify) + +- Traditional metrics such as visits (MAU, DAU, etc) and page views. Currently + ~50% of all Spotifiers use Backstage on a monthly basis, even though the + percentage of engineers is below 50%. Most engineers actually use Backstage on + a daily basis. + +Again, any feedback is appreciated. Please use the Edit button at the top of the +page to make a suggestion. + +_**Note!** It might be tempting to try to optimize Backstage usage and +"engagement". Even though you want to consolidate all your tooling and technical +documentation in Backstage, it is important to remember that time spent in +Backstage is time not spent writing code_ 🙃 diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index 9f1d6d3291..f2c6dad61f 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -1,6 +1,7 @@ --- id: architecture-overview title: Architecture overview +description: Documentation on Architecture overview --- ## Overview @@ -171,21 +172,19 @@ The frontend container can be built with a provided command. ```bash yarn install yarn tsc -yarn build -yarn run docker-build +yarn run docker-build:app ``` Running this will simply generate a Docker container containing the contents of -the UIs `dist` directory. The resulting container will be about 50MB in size. +the UIs `dist` directory. -The backend container can be built by running the following command in the -`packages/backend` directory. +The backend container can be built by running the following command: ```bash -yarn run build-image +yarn run docker-build ``` -This will create a ~500MB container called `example-backend`. +This will create a container called `example-backend`. The lighthouse-audit-service container is already publicly available in Docker Hub and can be downloaded and ran with diff --git a/docs/overview/architecture-terminology.md b/docs/overview/architecture-terminology.md index aee581f927..092ca7fb01 100644 --- a/docs/overview/architecture-terminology.md +++ b/docs/overview/architecture-terminology.md @@ -1,6 +1,7 @@ --- id: architecture-terminology title: Architecture terminology +description: Documentation on Architecture terminology --- Backstage is constructed out of three parts. We separate Backstage in this way diff --git a/docs/overview/background.md b/docs/overview/background.md new file mode 100644 index 0000000000..486260bac3 --- /dev/null +++ b/docs/overview/background.md @@ -0,0 +1,31 @@ +--- +id: background +title: The Spotify Story +description: Documentation on Background and Story behind making of Backstage +--- + +Backstage was born out of necessity at Spotify. We found that as we grew, our +infrastructure was becoming more fragmented, our engineers less productive. + +Instead of building and testing code, teams were spending more time looking for +the right information just to get started. “Where’s the API for that service +we’re all supposed to be using?” “What version of that framework is everyone +on?” “This service isn’t responding, who owns it?” “I can’t find documentation +for anything!” + +Context switching and cognitive overload were dragging engineers down, day by +day. We needed to make it easier for our engineers to do their work without +having to become an expert in every aspect of infrastructure tooling. + +Our idea was to centralize and simplify end-to-end software development with an +abstraction layer that sits on top of all of our infrastructure and developer +tooling. That’s Backstage. + +It’s a developer portal powered by a centralized service catalog — with a plugin +architecture that makes it endlessly extensible and customizable. + +Manage all your services, software, tooling, and testing in Backstage. Start +building a new microservice using an automated template in Backstage. Create, +maintain, and find the documentation for all that software in Backstage. + +One place for everything. Accessible to everyone. diff --git a/docs/overview/logos.md b/docs/overview/logos.md new file mode 100644 index 0000000000..2a684420bc --- /dev/null +++ b/docs/overview/logos.md @@ -0,0 +1,42 @@ +--- +id: logos +title: Logos +sidebar_label: Logo assets +description: Guidelines for how to use the Backstage logos and icons +--- + +Guidelines for how to use the Backstage logo and icon can be found +[here](/logo_assets/Backstage_Identity_Assets_Overview.pdf). The assets below +are all in `.svg` format. Other formats are available in the +[repository](https://github.com/spotify/backstage/tree/master/microsite/static/logo_assets). + +## Backstage logo + + + + + + + + + + + + + +## Backstage icon + +
+ + + + + + + + + + + + +
diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md index 6acea71509..d6c1d2d641 100644 --- a/docs/overview/roadmap.md +++ b/docs/overview/roadmap.md @@ -1,39 +1,135 @@ --- id: roadmap title: Project roadmap +description: Roadmap of Backstage Project --- -We created Backstage about 4 years ago. While our internal version of Backstage -has had the benefit of time to mature and evolve, the first iteration of our -open source version is still nascent. We are envisioning three phases of the -project and we have already begun work on various aspects of these phases: +## Current status + +> Backstage is currently under rapid development. This means that you can expect +> APIs and features to evolve. It is also recommended that teams who adopt +> Backstage today upgrade their installation as new +> [releases](https://github.com/spotify/backstage/releases) become available, as +> Backwards compatibility is not yet guaranteed. + +## Phases + +We have divided the project into three high-level _phases_: - 🐣 **Phase 1:** Extensible frontend platform (Done ✅) - You will be able to easily create a single consistent UI layer for your internal infrastructure and tools. A set of reusable - [UX patterns and components](http://backstage.io/storybook) help ensure a + [UX patterns and components](https://backstage.io/storybook) help ensure a consistent experience between tools. - 🐢 **Phase 2:** Service Catalog ([alpha released](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha)) - With a single catalog, Backstage makes it easy for a team to manage ten services — and makes it possible for your company to manage thousands of them. - Developers can get a uniform overview of all their software and related - resources, regardless of how and where they are running, as well as an easy - way to onboard and manage those resources. - 🐇 **Phase 3:** Ecosystem (later) - Everyone's infrastructure stack is different. By fostering a vibrant community of contributors we hope to provide an ecosystem of Open Source plugins/integrations that allows you to pick the tools that match your stack. -Check out our [Milestones](https://github.com/spotify/backstage/milestones) and -open [RFCs](https://github.com/spotify/backstage/labels/rfc) how they relate to -the three Phases outlined above. +## Detailed roadmap -Our vision for Backstage is for it to become the trusted standard toolbox (read: -UX layer) for the open source infrastructure landscape. Think of it like -Kubernetes for developer experience. We realize this is an ambitious goal. We -can’t do it alone. If this sounds interesting or you'd like to help us shape our -product vision, we'd love to talk. You can email me directly: +If you have questions about the roadmap or want to provide feedback, we would +love to hear from you! Please create an +[Issue](https://github.com/spotify/backstage/issues/new/choose), ping us on +[Discord](https://discord.gg/EBHEGzX) or reach out directly at [alund@spotify.com](mailto:alund@spotify.com). + +Want to help out? Awesome ❤️ Head over to +[CONTRIBUTING](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md) +guidelines to get started. + +### Ongoing work 🚧 + +- **[Plugins for managing micro services end-2-end](https://github.com/spotify/backstage/milestone/14)** - + Out of the box Backstage will ship with a set of plugins (Overview, CI, API + and Docs) that will demonstrate how a user can manage a micro service and + follow a change all the way out in production. Completing this work will make + it much easier to see how a plugin can be built that integrates with the + Backstage Service Catalog. + +- **Backstage Design System** - By providing design guidelines for common plugin + layouts together, rich set of reusable UI components + ([Storybook](https://backstage.io/storybook)) and Figma design resources. The + Design System will make it easy to design and build plugins that are + consistent across the platform -- supporting both developers and designers. + +- **[TechDocs v1](https://github.com/spotify/backstage/milestone/16)** - Our + docs-like-code feature TechDocs working end to end. + +- **[Initial GraphQL API](https://github.com/spotify/backstage/milestone/13)** - + A GraphQL API will open up the rich metadata provided by Backstage in a single + query. Plugins can easily query this API as well as extend the model where + needed. + +- **Production deployments** - Provide instructions and default configurations + (e.g. through Helm charts) for easy deployments of Backstage and its + subsystems on Kubernetes. + +- **Cloud Cost Insights plugin (from Spotify)** - Spotify teams are fully + responsible for their own software, including the cost of the cloud resources + they use. By making our internal cost insights plugin available as open source + you will also be able to treat cost as an engineering problem, and make it + easy for your engineers to see their spend and where there's opportunity to + reduce waste. + +- Further improvements to platform documentation + +### Plugins + +Building and maintaining [plugins](https://backstage.io/plugins) is the work of +the entire Backstage community. + +A list of plugins that are in development is +[available here](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+label%3Aplugin+sort%3Areactions-%2B1-desc). +We strongly recommend to upvote 👍 plugins you are interested in. This helps us +and the community prioritize what plugins to build. + +Are you missing a plugin for your favorite tool? Please +[suggest a new one](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). +Chances are that someone will jump in and help build it. + +### Future work 🔮 + +- **[Backstage platform is stable](https://github.com/spotify/backstage/milestone/19)** - + The platform APIs and features are stable and can be depended on for + production use. After this plugins will require little to no maintenance. + +- **Deploy a product demo at `demo.backstage.io`** - Deploy a typical Backstage + deployment available publicly so that people can click around and get a feel + for the product without having to install anything. + +- **[Global search](https://github.com/spotify/backstage/issues/1499)** - Extend + the basic search available in the Backstage Service Catalog with a global + search experience. Long term this search solution should be extensible, making + it possible for you add custom search results. + +- **[[TechDocs V.2] Stabilization release](https://github.com/spotify/backstage/milestone/17)** - + Platform stability and compatibility improvements. + +- **Additional auth providers** - Backstage should work for most (all!) auth + solutions. Since Backstage can be used by companies regardless of what cloud + (or on prem) you are using we are especially keen to get auth support for + [AWS](https://github.com/spotify/backstage/issues/290), + [Azure](https://github.com/spotify/backstage/issues/348) and others. + +### Completed milestones ✅ + +- [Plugin marketplace](https://backstage.io/plugins) +- [Improved and move documentation to backstage.io](https://backstage.io/docs/overview/what-is-backstage) +- [Backstage Service Catalog (alpha)](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha) +- [Backstage Software Templates (alpha)](https://backstage.io/blog/2020/08/05/announcing-backstage-software-templates) +- [Make it possible to add custom auth providers](https://backstage.io/blog/2020/07/01/how-to-enable-authentication-in-backstage-using-passport) +- [TechDocs v0](https://github.com/spotify/backstage/milestone/15) +- CI plugins: CircleCI, Jenkins, GitHub Actions and TravisCI +- [Service API documentation](https://github.com/spotify/backstage/pull/1737) +- Backstage Service Catalog can read from: GitHub, GitLab, + [Bitbucket](https://github.com/spotify/backstage/pull/1938) +- Support auth providers: Google, Okta, GitHub, GitLab, + [auth0](https://github.com/spotify/backstage/pull/1611), + [AWS](https://github.com/spotify/backstage/pull/1990) diff --git a/docs/overview/support.md b/docs/overview/support.md index 9e4e9eca15..6e00c17fab 100644 --- a/docs/overview/support.md +++ b/docs/overview/support.md @@ -1,6 +1,7 @@ --- id: support title: Support and community +description: Support and Community Details and Links --- - [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the diff --git a/docs/overview/vision.md b/docs/overview/vision.md new file mode 100644 index 0000000000..df75a4f0a6 --- /dev/null +++ b/docs/overview/vision.md @@ -0,0 +1,23 @@ +--- +id: vision +title: Vision +description: Goal is to provide engineers with the best developer experience in +the world +--- + +Our goal is to provide engineers with the best developer experience in the +world. + +A fantastic developer experience leads to happy, creative and productive +engineers. Our belief is that engineers should not have to be experts in various +infrastructure tools to be productive. Infrastructure should be abstracted away +so that you can return to building and scaling, quickly and safely. + +![](https://backstage.io/animations/backstage-logos-hero-8.gif) + +We are working on making Backstage the trusted standard toolbox (read: UX layer) +for the open source infrastructure landscape. Think of it like Kubernetes for +developer experience. We realize this is an ambitious goal. We can’t do it +alone. If this sounds interesting or you'd like to help us shape our product +vision, we'd love to talk. You can email me directly: +[alund@spotify.com](mailto:alund@spotify.com). diff --git a/docs/overview/what-is-backstage.md b/docs/overview/what-is-backstage.md index 163b6c219b..0d16943c9b 100644 --- a/docs/overview/what-is-backstage.md +++ b/docs/overview/what-is-backstage.md @@ -1,6 +1,8 @@ --- id: what-is-backstage title: What is Backstage? +description: Backsatge is an open platform for building developer portals. +Powered by a centralized service catalog, Backstage restores order to your microservices and infrastructure --- ![service-catalog](https://backstage.io/blog/assets/6/header.png) @@ -18,30 +20,36 @@ Out of the box, Backstage includes: - [Backstage Service Catalog](../features/software-catalog/index.md) for managing all your software (microservices, libraries, data pipelines, websites, ML models, etc.) + - [Backstage Software Templates](../features/software-templates/index.md) for quickly spinning up new projects and standardizing your tooling with your organization’s best practices + - [Backstage TechDocs](../features/techdocs/README.md) for making it easy to create, maintain, find, and use technical documentation, using a "docs like code" approach + - Plus, a growing ecosystem of [open source plugins](https://github.com/spotify/backstage/tree/master/plugins) that further expand Backstage’s customizability and functionality -For more information go to [backstage.io](https://backstage.io) or join our -[Discord chatroom](https://discord.gg/EBHEGzX). - ### Benefits - For _engineering managers_, it allows you to maintain standards and best practices across the organization, and can help you manage your whole tech ecosystem, from migrations to test certification. + - For _end users_ (developers), it makes it fast and simple to build software components in a standardized way, and it provides a central place to manage all projects and documentation. + - For _platform engineers_, it enables extensibility and scalability by letting you easily integrate new tools and services (via plugins), as well as extending the functionality of existing ones. + - For _everyone_, it’s a single, consistent experience that ties all your infrastructure tooling, resources, standards, owners, contributors, and administrators together in one place. + +If you have questions or want support, please join our +[Discord chatroom](https://discord.gg/EBHEGzX). diff --git a/docs/plugins/add-to-marketplace.md b/docs/plugins/add-to-marketplace.md new file mode 100644 index 0000000000..59cca07bff --- /dev/null +++ b/docs/plugins/add-to-marketplace.md @@ -0,0 +1,26 @@ +--- +id: add-to-marketplace +title: Add to Marketplace +description: Documentation on Adding Plugin to Marketplace +--- + +## Adding a Plugin to the Marketplace + +To add a new plugin to the [plugin marketplace](https://backstage.io/plugins) +create a file in +[`microsite/data/plugins`](https://github.com/spotify/backstage/tree/master/microsite/data/plugins) +with your plugin's information. Example: + +```yaml +--- +title: Your Plugin +author: Your Name +authorUrl: # A link to information about the author E.g. Company url, github user profile, etc +category: Monitoring # A single category e.g. CI, Machine Learning, Services, Monitoring +description: A brief description of the plugin. # Max 170 characters +documentation: # A link to your documentation E.g. Your github README +iconUrl: # Used as the src attribute for your logo. +# You can provide an external url or add your logo under static/img and provide a path +# relative to static/ e.g. img/my-logo.png +npmPackageName: # Your npm package name E.g. '@backstage/plugin-' quotes are required +``` diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md index 986af66c20..516ba4b111 100644 --- a/docs/plugins/backend-plugin.md +++ b/docs/plugins/backend-plugin.md @@ -1,6 +1,7 @@ --- id: backend-plugin title: Backend plugin +description: Documentation on Backend plugin --- ## TODO diff --git a/docs/plugins/call-existing-api.md b/docs/plugins/call-existing-api.md index 075d4bdeb6..14ea4c1f5a 100644 --- a/docs/plugins/call-existing-api.md +++ b/docs/plugins/call-existing-api.md @@ -1,6 +1,174 @@ --- id: call-existing-api -title: Call existing API +title: Call Existing API +description: Describes the various options that Backstage frontend plugins have, +in communicating with service APIs that already exist --- -## TODO +This article describes the various options that Backstage frontend plugins have, +in communicating with service APIs that already exist. Each section below +describes a possible choice, and the circumstances under which it fits. + +In these examples, we will be ultimately requesting data from the fictional +FrobsCo API. + +## Issuing Requests Directly + +The most basic choice available is to issue requests directly from the plugin +frontend code to the FrobsCo API, using for example `fetch` or a support library +such as `axios`. + +Example: + +```ts +// Inside your component +fetch('https://api.frobsco.com/v1/list') + .then(response => response.json()) + .then(payload => setFrobs(payload as Frob[])); +``` + +Internally at Spotify, this has not been a very common choice. Third party APIs +are sometimes accessed like this. Just a handful of internal APIs also went +through the trouble of exposing themselves in a way that is useful directly from +a browser, but even then, often not from the public internet but only supporting +users that are already on the company VPN. + +This can be used when: + +- The API already does/exposes exactly what you need. +- The request/response patterns of the API match real world usage needs in + Backstage frontend plugins. For example, if the end use case is to show a + small summary in Backstage, but the only available API endpoint gives a 30 + megabyte blob with large amounts of redundant information, it would hurt the + end user experience. Particularly on mobile. The same goes for cases where you + want to show many individual pieces of information: if a common use case is to + show large tables where one API request per cell is necessary, the browser + will quickly become swamped and you may want to consider performing + aggregation elsewhere instead. +- The API can maintain interactive request/response times at your required peak + request rates. The end user experience will be degraded if they spend a lot of + time waiting for the data to arrive. +- The API endpoint is highly available. The browser does not have builtin + facilities for load balancing, service discovery, retries, health checks, + circuit breaking and similar. If the endpoint is occasionally down even for + short periods of time (e.g. during deploys), end users will quickly notice. +- The API is exposed over HTTPS (not just HTTP), and properly handles + [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). These are + requirements that the user's browser will impose for security reasons, and the + requests will be rejected otherwise. +- The API endpoint is easily reachable, in terms of network conditions, by end + users. This may be particularly relevant if your end users are outside of your + perimeter. +- The requests do not require secrets to be passed. This limitation does not + apply to OAuth tokens, which the frontend can negotiate and make proper use + of. + +## Using The Backstage Proxy + +Backstage has an optional proxy plugin for the backend, that can be used to +easily add proxy routes to downstream APIs. + +Example: + +```yaml +# In app-config.yaml +proxy: + '/frobs': http://api.frobsco.com/v1 +``` + +```ts +// Inside your component +const backendUrl = config.getString('backend.baseUrl'); +fetch(`${backendUrl}/proxy/frobs/list`) + .then(response => response.json()) + .then(payload => setFrobs(payload as Frob[])); +``` + +The proxy is powered by the `http-proxy-middleware` package. See +[Proxying](proxying.md) for a full description of its configuration options. + +Internally at Spotify, the proxy option has been the overwhelmingly most popular +choice for plugin makers. Since we have DNS based service discovery in place and +a microservices framework that made it trivial to expose plain HTTP, it has been +a matter of just adding a few lines of Backstage config to get the benefit of +being easily and robustly reachable from users' web browsers as well. + +This may be used instead of direct requests, when: + +- You need to perform HTTPS termination and/or CORS handling, because the API + itself is not supplying those. +- You need to inject a simple static secret into the requests, e.g. an + Authorization header that gets added to the request headers. +- You want to make use of other proxy facilities, such as retries, failover, + health checks, routing, request logging, rewrites, etc. +- You already have the Backstage backend itself exposed through your perimeter + and find it practical to have only one entry point to deal with, governing + ingress with just the Backstage config. + +## Creating a Backstage Backend Plugin + +Much like the Backstage frontend, the Backstage backend also has a plugin +system. The above mentioned proxy is actually one such plugin. If you were in +need of a more involved integration than just direct access to the FrobsCo API, +or if you needed to hold state, you may want to make such a plugin. + +Example: + +```ts +// Inside your component +const backendUrl = config.getString('backend.baseUrl'); +fetch(`${backendUrl}/frobs-aggregator/summary`) + .then(response => response.json()) + .then(payload => setSummary(payload as FrobSummary)); +``` + +```ts +// Inside a new frobs-aggregator backend plugin +router.use('/summary', async (req, res) => { + const agg = await Promise.all([ + fetch('https://api.frobsco.com/v1/list'), + fetch('http://flerps.partnercompany.com:8080/flerp-batch'), + database.currentThunk(), + ]).then(async ([frobs, flerps, thunk]) => { + return computeAggregate(await frobs.json(), await flerps.json(), thunk); + }); + res.status(200).send(agg); +}); +``` + +For a more detailed example, see +[the lighthouse plugin](https://github.com/spotify/backstage/tree/master/plugins/lighthouse) +that stores some state in a database and adds new capabilities to the underlying +API. + +Internally at Spotify, this has been a fairly popular choice for different +reasons. Commonly, the backend has been used as a caching and data massaging +layer for slow APIs or APIs whose request/response shapes or speeds were not +acceptable for direct use by frontends. For example, this has made it possible +to issue efficient batch queries from the frontend, e.g. in big lists or tables +that want to resolve a lot of sparse data from the larger list that an +underlying service supplies. + +This may be used instead of the above, when: + +- You need to perform complex model conversion, or protocol translation beyond + what the proxy handles. +- You want to perform aggregations or summaries on the backend instead of on the + frontend. +- You want to enable batching or caching of slower or more unreliable APIs. +- You need to maintain state for your plugin, perhaps using the builtin database + support in the backend. +- You need to inject secrets or in other ways negotiate with other parts of the + API or other services in order to perform your work. +- You want to enforce end user authentication / authorization for operations on + behalf of the API, have session handling, or similar. + +There is a balance to strike regarding when to make an entirely separate backend +for a purpose, and when to make a Backstage backend plugin that adapts something +that already exists. General advice is not easy to give, but contact us on +Discord if you have any questions, and we may be able to offer guidance. + +## Extending the GraphQL Model + +The extensible GraphQL backend layer is not built yet. This section will be +expanded when that happens. Stay tuned! diff --git a/docs/plugins/create-a-plugin.md b/docs/plugins/create-a-plugin.md index 0ca83b076c..bcb86024fd 100644 --- a/docs/plugins/create-a-plugin.md +++ b/docs/plugins/create-a-plugin.md @@ -1,6 +1,7 @@ --- id: create-a-plugin title: Create a Backstage Plugin +description: Documentation on How to Create a Backstage Plugin --- A Backstage Plugin adds functionality to Backstage. @@ -15,20 +16,16 @@ dependencies, then run the following on your command line (invoking the yarn create-plugin ``` -

- create plugin -

+![](../assets/getting-started/create-plugin_output.png) This will create a new Backstage Plugin based on the ID that was provided. It will be built and added to the Backstage App automatically. -_If `yarn start` is already running you should be able to see the default page -for your new plugin directly by navigating to -`http://localhost:3000/my-plugin`._ +> If `yarn start` is already running you should be able to see the default page +> for your new plugin directly by navigating to +> `http://localhost:3000/my-plugin`. -

- my plugin -

+![](../assets/my-plugin_screenshot.png) You can also serve the plugin in isolation by running `yarn start` in the plugin directory. Or by using the yarn workspace command, for example: @@ -40,7 +37,3 @@ yarn workspace @backstage/plugin-welcome start # Also supports --check This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. It is only meant for local development, and the setup for it can be found inside the plugin's `dev/` directory. - -[Next Step - Structure of a plugin](structure-of-a-plugin.md) - -[Back to Getting Started](../README.md) diff --git a/docs/plugins/existing-plugins.md b/docs/plugins/existing-plugins.md index 8598ae02c4..c1fbda379a 100644 --- a/docs/plugins/existing-plugins.md +++ b/docs/plugins/existing-plugins.md @@ -1,6 +1,7 @@ --- id: existing-plugins title: Existing plugins +description: Lists of existing open source plugins --- ## Open source plugins diff --git a/docs/plugins/index.md b/docs/plugins/index.md index bb8951f959..8a61aeee80 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -1,6 +1,7 @@ --- id: index -title: Intro +title: Intro to plugins +description: Documentation on Introduction to Plugins --- Backstage is a single-page application composed of a set of plugins. @@ -21,8 +22,15 @@ To create a plugin, follow the steps outlined [here](create-a-plugin.md). If you start developing a plugin that you aim to release as open source, we suggest that you create a new -[new Issue](https://github.com/spotify/backstage/issues/new?template=plugin_template.md). +[new Issue](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development. You can also use this process if you have an idea for a good plugin but you hope that someone else will pick up the work. + +## Integrate into the Service Catalog + +If your plugin isn't supposed to live as a standalone page, but rather needs to +be presented as a part of a Service Catalog (e.g. a separate tab or a card on an +"Overview" tab), then check out +[the instruction](integrating-plugin-into-service-catalog.md). on how to do it. diff --git a/docs/plugins/integrating-plugin-into-service-catalog.md b/docs/plugins/integrating-plugin-into-service-catalog.md new file mode 100644 index 0000000000..51d2331140 --- /dev/null +++ b/docs/plugins/integrating-plugin-into-service-catalog.md @@ -0,0 +1,125 @@ +--- +id: integrating-plugin-into-service-catalog +title: Integrate into the Service Catalog +description: Documentation on How to integrate plugin into service catalog +--- + +> This is an advanced use case and currently is an experimental feature. Expect +> API to change over time + +## Steps + +1. [Create a plugin](#create-a-plugin) +1. [Export a router with relative routes](#export-a-router) +1. [Import and use router in the APP](#import-and-use-router-in-the-app) + +### Create a plugin + +Follow the [same process](create-a-plugin.md) as for standalone plugin. You +should have a separate package in a folder, which represents your plugin. + +Example: + +``` +$ yarn create-plugin +> ? Enter an ID for the plugin [required] my-plugin +> ? Enter the owner(s) of the plugin. If specified, this will be added to CODEOWNERS for the plugin path. [optional] + +Creating the plugin... +``` + +### Export a router + +Now in the plugin you have a `Router.tsx` file in the `src` folder. By default +it contains only one example route. Create a routing structure needed for your +plugin, keeping in mind that the whole set of routes defined here are going to +be mounted under some different route in the App. + +Example: + +`my-plugin` consists of 2 different views - `/me` and `/about`. I envision +people integrating it into plugin catalog as a tab named "MyPlugin". Then, my +`Routes.tsx` for the plugin is going to look like: + +```tsx + + } /> + } /> + +``` + +(where MePage and AboutPage are 2 components defined in your plugin and imported +accordingly inside `Router.tsx`) + +> Pay attention, if your `MePage` references the `AboutPage` it needs to do it +> through link to `about`, not `/about`. This allows react-router v6 to enable +> its relative routing mechanism. Read more - +> https://reacttraining.com/blog/react-router-v6-pre/#relative-route-path-and-link-to + +### Import and use router in the APP + +In the `app/src/components/catalog/EntityPage.tsx` (app === your folder, +containing backstage app) import your created Router: + +```tsx +import { Router as MyPluginRouter } from '@backstage/plugin-my-plugin; +``` + +Now, you need to mount `MyPluginRouter` onto some route, for example if you had: + +```tsx +const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); +``` + +after you add your code it becomes: + +```tsx +const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + +); +``` + +All of magic happens thanks to the `EntityPageLayout` component, which comes as +an export from `@backstage/plugin-catalog` package. + +```tsx +type EntityPageLayoutContentProps = { + /** + * Going to be transformed into react-router v6 + * path under the hood. Read more at https://reacttraining.com/blog/react-router-v6-pre + */ + path: string; + /** + * Gets transformed into the title for the tab + */ + title: string; + /** + * Element that is rendered when the location + * matches the path provided + */ + element: JSX.Element; +}; +``` + +> You can either pass the entity from App to the plugin's router as a prop or +> use `useEntity` hook from `@backstage/plugin-catalog` directly inside your +> plugin. diff --git a/docs/plugins/plugin-development.md b/docs/plugins/plugin-development.md index b5c3240de0..c5d9763f7d 100644 --- a/docs/plugins/plugin-development.md +++ b/docs/plugins/plugin-development.md @@ -1,6 +1,7 @@ --- id: plugin-development -title: Plugin Development in Backstage +title: Plugin Development +description: Documentation on Plugin Development --- Backstage plugins provide features to a Backstage App. @@ -10,28 +11,12 @@ type of content. Plugins all use a common set of platform APIs and reusable UI components. Plugins can fetch data from external sources using the regular browser APIs or by depending on external modules to do the work. - - ## Developing guidelines - Consider writing plugins in `TypeScript`. - Plan the directory structure of your plugin so that it becomes easy to manage. -- Prefer using the Backstage components, otherwise go with - [Material-UI](https://material-ui.com/). +- Prefer using the [Backstage components](https://backstage.io/storybook), + otherwise go with [Material-UI](https://material-ui.com/). - Check out the shared Backstage APIs before building a new one. ## Plugin concepts / API diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index 4cf5b4c025..2f809ffd10 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -1,6 +1,79 @@ --- id: proxying title: Proxying +description: Documentation on Proxying --- -## TODO +## Overview + +The Backstage backend comes packaged with a basic HTTP proxy, that can aid in +reaching backend service APIs from frontend plugin code. See +[Call Existing API](call-existing-api.md) for a description of when the proxy +can be the best choice for communicating with an API. + +## Getting Started + +The plugin is already added to a default Backstage project. + +In `packages/backend/src/index.ts`: + +```ts +const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); + +const service = createServiceBuilder(module) + .loadConfig(configReader) + /** ... other routers ... */ + .addRouter('/proxy', await proxy(proxyEnv, '/proxy')); +``` + +## Configuration + +Configuration for the proxy plugin lives under a `proxy` root key of your +`app-config.yaml` file. + +Example: + +```yaml +# in app-config.yaml +proxy: + '/simple-example': http://simple.example.com:8080 + '/larger-example/v1': + target: http://larger.example.com:8080/svc.v1 + headers: + Authorization: + $secret: + env: EXAMPLE_AUTH_HEADER +``` + +Each key under the proxy configuration entry is a route to match, below the +prefix that the proxy plugin is mounted on. It must start with a slash. For +example, if the backend mounts the proxy plugin as `/proxy`, the above +configuration will lead to the proxy acting on backend requests to +`/proxy/simple-example/...` and `/proxy/larger-example/v1/...`. + +The value inside each route is either a simple URL string, or an object on the +format accepted by +[http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware). It +is also possible to limit the forwarded HTTP methods with the configuration +`allowedMethods`, for example `allowedMethods: ['GET']` to enforce read-only +access. + +If the value is a string, it is assumed to correspond to: + +```yaml +target: +changeOrigin: true +pathRewrite: + '^/': '/' +``` + +When the target is an object, it is given verbatim to `http-proxy-middleware` +except with the following caveats for convenience: + +- If `changeOrigin` is not specified, it is set to `true`. This is the most + commonly useful value. +- If `pathRewrite` is not specified, it is set to a single rewrite that removes + the entire prefix and route. In the above example, a rewrite of + `'^/proxy/larger-example/v1/': '/'` is added. That means that a request to + `/proxy/larger-example/v1/some/path` will be translated to a request to + `http://larger.example.com:8080/svc.v1/some/path`. diff --git a/docs/plugins/publish-private.md b/docs/plugins/publish-private.md index 6361f70854..d04449d465 100644 --- a/docs/plugins/publish-private.md +++ b/docs/plugins/publish-private.md @@ -1,6 +1,7 @@ --- id: publish-private title: Publish private +description: Documentation on How to Publish private --- ## TODO diff --git a/docs/plugins/publishing.md b/docs/plugins/publishing.md index 1d16ebb047..0ed85e8cdc 100644 --- a/docs/plugins/publishing.md +++ b/docs/plugins/publishing.md @@ -1,6 +1,7 @@ --- id: publishing title: Publishing +description: Documentation on Publishing NPM packages --- ## NPM @@ -39,4 +40,18 @@ $ git push origin -u new-release And then create a PR. Once the PR is approved and merged into master, the master build will publish new versions of all bumped packages. +### Include new changes in existing release PR + +If you want to include some last minute changes to an existing release PR, +follow these instructions: + +```sh +$ git checkout master +$ git pull +$ git checkout new-release +$ git reset --hard master +$ yarn release +$ git push --force +``` + [Back to Docs](../README.md) diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index 20e72c539d..f061d2f4a8 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -1,6 +1,7 @@ --- id: structure-of-a-plugin title: Structure of a Plugin +description: Details about structure of a plugin --- Nice, you have a new plugin! We'll soon see how we can develop it into doing diff --git a/docs/plugins/testing.md b/docs/plugins/testing.md index 110bc97515..696f8b1368 100644 --- a/docs/plugins/testing.md +++ b/docs/plugins/testing.md @@ -1,6 +1,7 @@ --- id: testing title: Testing with Jest +description: Documentation on How to do unit testing with Jest --- Backstage uses [Jest](https://facebook.github.io/jest/) for all our unit testing diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md index bc38656939..ebb8a6b503 100644 --- a/docs/reference/createPlugin-feature-flags.md +++ b/docs/reference/createPlugin-feature-flags.md @@ -1,6 +1,7 @@ --- id: createPlugin-feature-flags title: createPlugin - feature flags +description: Documentation on createPlugin - feature flags --- The `featureFlags` object passed to the `register` function makes it possible diff --git a/docs/reference/createPlugin-router.md b/docs/reference/createPlugin-router.md index ebefa13bb0..89ee44e558 100644 --- a/docs/reference/createPlugin-router.md +++ b/docs/reference/createPlugin-router.md @@ -1,6 +1,7 @@ --- id: createPlugin-router title: createPlugin - router +description: Documentation on createPlugin - router --- The router that is passed to the `register` function makes it possible for @@ -37,5 +38,3 @@ const myPluginRouteRef = createRouteRef({ title: 'My Plugin', }); ``` - -[Back to References](../README.md) diff --git a/docs/reference/createPlugin.md b/docs/reference/createPlugin.md index 0cabe63b39..45e3303124 100644 --- a/docs/reference/createPlugin.md +++ b/docs/reference/createPlugin.md @@ -1,6 +1,7 @@ --- id: createPlugin title: createPlugin +description: Documentation on createPlugin --- Taking a plugin config as argument and returns a new plugin. diff --git a/docs/reference/utility-apis/AlertApi.md b/docs/reference/utility-apis/AlertApi.md index 3490c1900e..d000a6d6a2 100644 --- a/docs/reference/utility-apis/AlertApi.md +++ b/docs/reference/utility-apis/AlertApi.md @@ -1,7 +1,7 @@ # AlertApi The AlertApi type is defined at -[packages/core-api/src/apis/definitions/AlertApi.ts:29](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/AlertApi.ts#L29). +[packages/core-api/src/apis/definitions/AlertApi.ts:29](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AlertApi.ts#L29). The following Utility API implements this type: [alertApiRef](./README.md#alert) @@ -38,7 +38,7 @@ export type AlertMessage = { Defined at -[packages/core-api/src/apis/definitions/AlertApi.ts:19](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/AlertApi.ts#L19). +[packages/core-api/src/apis/definitions/AlertApi.ts:19](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AlertApi.ts#L19). Referenced by: [post](#post), [alert\$](#alert). @@ -67,7 +67,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L53). Referenced by: [alert\$](#alert). @@ -86,7 +86,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -109,6 +109,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/AppThemeApi.md b/docs/reference/utility-apis/AppThemeApi.md index eda1bafef0..e7d5296ceb 100644 --- a/docs/reference/utility-apis/AppThemeApi.md +++ b/docs/reference/utility-apis/AppThemeApi.md @@ -1,7 +1,7 @@ # AppThemeApi The AppThemeApi type is defined at -[packages/core-api/src/apis/definitions/AppThemeApi.ts:50](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/AppThemeApi.ts#L50). +[packages/core-api/src/apis/definitions/AppThemeApi.ts:50](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L50). The following Utility API implements this type: [appThemeApiRef](./README.md#apptheme) @@ -76,7 +76,7 @@ export type AppTheme = { Defined at -[packages/core-api/src/apis/definitions/AppThemeApi.ts:24](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/AppThemeApi.ts#L24). +[packages/core-api/src/apis/definitions/AppThemeApi.ts:24](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L24). Referenced by: [getInstalledThemes](#getinstalledthemes). @@ -87,7 +87,7 @@ export type BackstagePalette = Palette & Palette Defined at -[packages/theme/src/types.ts:67](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/theme/src/types.ts#L67). +[packages/theme/src/types.ts:70](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/theme/src/types.ts#L70). Referenced by: [BackstageTheme](#backstagetheme). @@ -100,7 +100,7 @@ export interface BackstageTheme extends Theme { Defined at -[packages/theme/src/types.ts:70](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/theme/src/types.ts#L70). +[packages/theme/src/types.ts:73](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/theme/src/types.ts#L73). Referenced by: [AppTheme](#apptheme). @@ -129,7 +129,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L53). Referenced by: [activeThemeId\$](#activethemeid). @@ -148,7 +148,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -204,7 +204,7 @@ type PaletteAdditions = { Defined at -[packages/theme/src/types.ts:23](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/theme/src/types.ts#L23). +[packages/theme/src/types.ts:23](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/theme/src/types.ts#L23). Referenced by: [BackstagePalette](#backstagepalette). @@ -227,6 +227,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/BackstageIdentityApi.md b/docs/reference/utility-apis/BackstageIdentityApi.md index f75918a8b3..87318ecff5 100644 --- a/docs/reference/utility-apis/BackstageIdentityApi.md +++ b/docs/reference/utility-apis/BackstageIdentityApi.md @@ -1,16 +1,20 @@ # BackstageIdentityApi The BackstageIdentityApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:144](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L144). +[packages/core-api/src/apis/definitions/auth.ts:144](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L144). The following Utility APIs implement this type: +- [auth0AuthApiRef](./README.md#auth0auth) + - [githubAuthApiRef](./README.md#githubauth) - [gitlabAuthApiRef](./README.md#gitlabauth) - [googleAuthApiRef](./README.md#googleauth) +- [microsoftAuthApiRef](./README.md#microsoftauth) + - [oktaAuthApiRef](./README.md#oktaauth) ## Members @@ -62,7 +66,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getBackstageIdentity](#getbackstageidentity). @@ -83,6 +87,6 @@ export type BackstageIdentity = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:157](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L157). +[packages/core-api/src/apis/definitions/auth.ts:157](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L157). Referenced by: [getBackstageIdentity](#getbackstageidentity). diff --git a/docs/reference/utility-apis/Config.md b/docs/reference/utility-apis/Config.md index c0264489fc..9374fb9962 100644 --- a/docs/reference/utility-apis/Config.md +++ b/docs/reference/utility-apis/Config.md @@ -1,13 +1,19 @@ # Config The Config type is defined at -[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/config/src/types.ts#L32). +[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/config/src/types.ts#L32). The following Utility API implements this type: [configApiRef](./README.md#config) ## Members +### has() + +
+has(key: string): boolean
+
+ ### keys()
@@ -17,13 +23,13 @@ keys(): string[]
 ### get()
 
 
-get(key: string): JsonValue
+get(key?: string): JsonValue
 
### getOptional()
-getOptional(key: string): JsonValue | undefined
+getOptional(key?: string): JsonValue | undefined
 
### getConfig() @@ -106,10 +112,12 @@ These types are part of the API declaration, but may not be unique to this API.
 export type Config = {
+  has(key: string): boolean;
+
   keys(): string[];
 
-  get(key: string): JsonValue;
-  getOptional(key: string): JsonValue | undefined;
+  get(key?: string): JsonValue;
+  getOptional(key?: string): JsonValue | undefined;
 
   getConfig(key: string): Config;
   getOptionalConfig(key: string): Config | undefined;
@@ -132,7 +140,7 @@ export type Config = {
 
Defined at -[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/config/src/types.ts#L32). +[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/config/src/types.ts#L32). Referenced by: [getConfig](#getconfig), [getOptionalConfig](#getoptionalconfig), [getConfigArray](#getconfigarray), @@ -145,7 +153,7 @@ export type JsonArray = JsonValue[]
Defined at -[packages/config/src/types.ts:18](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/config/src/types.ts#L18). +[packages/config/src/types.ts:18](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/config/src/types.ts#L18). Referenced by: [JsonValue](#jsonvalue). @@ -156,7 +164,7 @@ export type JsonObject = { [key in string]?: JsonValue Defined at -[packages/config/src/types.ts:17](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/config/src/types.ts#L17). +[packages/config/src/types.ts:17](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/config/src/types.ts#L17). Referenced by: [JsonValue](#jsonvalue). @@ -173,7 +181,7 @@ export type JsonValue = Defined at -[packages/config/src/types.ts:19](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/config/src/types.ts#L19). +[packages/config/src/types.ts:19](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/config/src/types.ts#L19). Referenced by: [get](#get), [getOptional](#getoptional), [JsonObject](#jsonobject), [JsonArray](#jsonarray), [Config](#config). diff --git a/docs/reference/utility-apis/DiscoveryApi.md b/docs/reference/utility-apis/DiscoveryApi.md new file mode 100644 index 0000000000..39902789cd --- /dev/null +++ b/docs/reference/utility-apis/DiscoveryApi.md @@ -0,0 +1,24 @@ +# DiscoveryApi + +The DiscoveryApi type is defined at +[packages/core-api/src/apis/definitions/DiscoveryApi.ts:30](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L30). + +The following Utility API implements this type: +[discoveryApiRef](./README.md#discovery) + +## Members + +### getBaseUrl() + +Returns the HTTP base backend URL for a given plugin, without a trailing slash. + +This method must always be called just before making a request. as opposed to +fetching the URL when constructing an API client. That is to ensure that more +flexible routing patterns can be supported. + +For example, asking for the URL for `auth` may return something like +`https://backstage.example.com/api/auth` + +
+getBaseUrl(pluginId: string): Promise<string>
+
diff --git a/docs/reference/utility-apis/ErrorApi.md b/docs/reference/utility-apis/ErrorApi.md index a630e65592..c05f060ec3 100644 --- a/docs/reference/utility-apis/ErrorApi.md +++ b/docs/reference/utility-apis/ErrorApi.md @@ -1,7 +1,7 @@ # ErrorApi The ErrorApi type is defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:53](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/ErrorApi.ts#L53). +[packages/core-api/src/apis/definitions/ErrorApi.ts:53](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/ErrorApi.ts#L53). The following Utility API implements this type: [errorApiRef](./README.md#error) @@ -41,7 +41,7 @@ type Error = { Defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:24](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/ErrorApi.ts#L24). +[packages/core-api/src/apis/definitions/ErrorApi.ts:24](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/ErrorApi.ts#L24). Referenced by: [post](#post), [error\$](#error). @@ -58,7 +58,7 @@ export type ErrorContext = { Defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:33](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/ErrorApi.ts#L33). +[packages/core-api/src/apis/definitions/ErrorApi.ts:33](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/ErrorApi.ts#L33). Referenced by: [post](#post), [error\$](#error). @@ -87,7 +87,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L53). Referenced by: [error\$](#error). @@ -106,7 +106,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -129,6 +129,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/FeatureFlagsApi.md b/docs/reference/utility-apis/FeatureFlagsApi.md index 16e30a1c60..5efdb176a4 100644 --- a/docs/reference/utility-apis/FeatureFlagsApi.md +++ b/docs/reference/utility-apis/FeatureFlagsApi.md @@ -1,7 +1,7 @@ # FeatureFlagsApi The FeatureFlagsApi type is defined at -[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:41](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L41). +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:41](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L41). The following Utility API implements this type: [featureFlagsApiRef](./README.md#featureflags) diff --git a/docs/reference/utility-apis/IdentityApi.md b/docs/reference/utility-apis/IdentityApi.md index f26ad4b6b3..4e37ad5300 100644 --- a/docs/reference/utility-apis/IdentityApi.md +++ b/docs/reference/utility-apis/IdentityApi.md @@ -1,7 +1,7 @@ # IdentityApi The IdentityApi type is defined at -[packages/core-api/src/apis/definitions/IdentityApi.ts:22](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/IdentityApi.ts#L22). +[packages/core-api/src/apis/definitions/IdentityApi.ts:22](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/IdentityApi.ts#L22). The following Utility API implements this type: [identityApiRef](./README.md#identity) @@ -76,6 +76,6 @@ export type ProfileInfo = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:172](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L172). +[packages/core-api/src/apis/definitions/auth.ts:172](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L172). Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/OAuthApi.md b/docs/reference/utility-apis/OAuthApi.md index 766124ccb4..e73d5f645f 100644 --- a/docs/reference/utility-apis/OAuthApi.md +++ b/docs/reference/utility-apis/OAuthApi.md @@ -1,7 +1,7 @@ # OAuthApi The OAuthApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:67](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L67). +[packages/core-api/src/apis/definitions/auth.ts:67](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L67). The following Utility APIs implement this type: @@ -11,6 +11,8 @@ The following Utility APIs implement this type: - [googleAuthApiRef](./README.md#googleauth) +- [microsoftAuthApiRef](./README.md#microsoftauth) + - [oauth2ApiRef](./README.md#oauth2) - [oktaAuthApiRef](./README.md#oktaauth) @@ -88,7 +90,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getAccessToken](#getaccesstoken). @@ -114,6 +116,6 @@ export type OAuthScope = string | string[] Defined at -[packages/core-api/src/apis/definitions/auth.ts:38](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L38). +[packages/core-api/src/apis/definitions/auth.ts:38](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L38). Referenced by: [getAccessToken](#getaccesstoken). diff --git a/docs/reference/utility-apis/OAuthRequestApi.md b/docs/reference/utility-apis/OAuthRequestApi.md index b3e0d0d979..c6b9e09189 100644 --- a/docs/reference/utility-apis/OAuthRequestApi.md +++ b/docs/reference/utility-apis/OAuthRequestApi.md @@ -1,7 +1,7 @@ # OAuthRequestApi The OAuthRequestApi type is defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:99](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L99). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:99](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L99). The following Utility API implements this type: [oauthRequestApiRef](./README.md#oauthrequest) @@ -72,7 +72,7 @@ export type AuthProvider = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:27](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L27). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:27](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L27). Referenced by: [AuthRequesterOptions](#authrequesteroptions), [PendingAuthRequest](#pendingauthrequest). @@ -96,7 +96,7 @@ export type AuthRequester<AuthResponse> = ( Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:66](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L66). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:66](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L66). Referenced by: [createAuthRequester](#createauthrequester). @@ -121,7 +121,7 @@ export type AuthRequesterOptions<AuthResponse> = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:43](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L43). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:43](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L43). Referenced by: [createAuthRequester](#createauthrequester). @@ -150,7 +150,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L53). Referenced by: [authRequest\$](#authrequest). @@ -169,7 +169,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -204,7 +204,7 @@ export type PendingAuthRequest = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:77](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L77). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:77](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L77). Referenced by: [authRequest\$](#authrequest). @@ -227,6 +227,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/OpenIdConnectApi.md b/docs/reference/utility-apis/OpenIdConnectApi.md index 7bc571ecb9..41a3247af6 100644 --- a/docs/reference/utility-apis/OpenIdConnectApi.md +++ b/docs/reference/utility-apis/OpenIdConnectApi.md @@ -1,12 +1,16 @@ # OpenIdConnectApi The OpenIdConnectApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:104](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L104). +[packages/core-api/src/apis/definitions/auth.ts:104](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L104). The following Utility APIs implement this type: +- [auth0AuthApiRef](./README.md#auth0auth) + - [googleAuthApiRef](./README.md#googleauth) +- [microsoftAuthApiRef](./README.md#microsoftauth) + - [oauth2ApiRef](./README.md#oauth2) - [oktaAuthApiRef](./README.md#oktaauth) @@ -70,6 +74,6 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getIdToken](#getidtoken). diff --git a/docs/reference/utility-apis/ProfileInfoApi.md b/docs/reference/utility-apis/ProfileInfoApi.md index 68b0dea194..09c0f88f83 100644 --- a/docs/reference/utility-apis/ProfileInfoApi.md +++ b/docs/reference/utility-apis/ProfileInfoApi.md @@ -1,16 +1,20 @@ # ProfileInfoApi The ProfileInfoApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:127](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L127). +[packages/core-api/src/apis/definitions/auth.ts:127](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L127). The following Utility APIs implement this type: +- [auth0AuthApiRef](./README.md#auth0auth) + - [githubAuthApiRef](./README.md#githubauth) - [gitlabAuthApiRef](./README.md#gitlabauth) - [googleAuthApiRef](./README.md#googleauth) +- [microsoftAuthApiRef](./README.md#microsoftauth) + - [oauth2ApiRef](./README.md#oauth2) - [oktaAuthApiRef](./README.md#oktaauth) @@ -61,7 +65,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getProfile](#getprofile). @@ -89,6 +93,6 @@ export type ProfileInfo = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:172](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L172). +[packages/core-api/src/apis/definitions/auth.ts:172](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L172). Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/README.md b/docs/reference/utility-apis/README.md index fbfbbc475e..cfdc3b6ef8 100644 --- a/docs/reference/utility-apis/README.md +++ b/docs/reference/utility-apis/README.md @@ -1,61 +1,79 @@ ---- -id: README -title: Utility API References ---- +# Backstage Core Utility APIs The following is a list of all Utility APIs defined by `@backstage/core`. They are available to use by plugins and components, and can be accessed using the `useApi` hook, also provided by `@backstage/core`. For more information, see https://github.com/spotify/backstage/blob/master/docs/api/utility-apis.md. -## alert +### alert Used to report alerts and forward them to the app Implemented type: [AlertApi](./AlertApi.md) ApiRef: -[alertApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/AlertApi.ts#L41) +[alertApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AlertApi.ts#L41) -## appTheme +### appTheme API Used to configure the app theme, and enumerate options Implemented type: [AppThemeApi](./AppThemeApi.md) ApiRef: -[appThemeApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/AppThemeApi.ts#L74) +[appThemeApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L74) -## config +### auth0Auth + +Provides authentication towards Auth0 APIs + +Implemented types: [OpenIdConnectApi](./OpenIdConnectApi.md), +[ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), +[SessionStateApi](./SessionStateApi.md) + +ApiRef: +[auth0AuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L273) + +### config Used to access runtime configuration Implemented type: [Config](./Config.md) ApiRef: -[configApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/ConfigApi.ts#L22) +[configApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/ConfigApi.ts#L22) -## error +### discovery + +Provides service discovery of backend plugins + +Implemented type: [DiscoveryApi](./DiscoveryApi.md) + +ApiRef: +[discoveryApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L44) + +### error Used to report errors and forward them to the app Implemented type: [ErrorApi](./ErrorApi.md) ApiRef: -[errorApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/ErrorApi.ts#L65) +[errorApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/ErrorApi.ts#L65) -## featureFlags +### featureFlags Used to toggle functionality in features across Backstage Implemented type: [FeatureFlagsApi](./FeatureFlagsApi.md) ApiRef: -[featureFlagsApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L58) +[featureFlagsApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L58) -## githubAuth +### githubAuth -Provides authentication towards Github APIs +Provides authentication towards GitHub APIs Implemented types: [OAuthApi](./OAuthApi.md), [ProfileInfoApi](./ProfileInfoApi.md), @@ -63,11 +81,11 @@ Implemented types: [OAuthApi](./OAuthApi.md), [SessionStateApi](./SessionStateApi.md) ApiRef: -[githubAuthApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L230) +[githubAuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L230) -## gitlabAuth +### gitlabAuth -Provides authentication towards Gitlab APIs +Provides authentication towards GitLab APIs Implemented types: [OAuthApi](./OAuthApi.md), [ProfileInfoApi](./ProfileInfoApi.md), @@ -75,9 +93,9 @@ Implemented types: [OAuthApi](./OAuthApi.md), [SessionStateApi](./SessionStateApi.md) ApiRef: -[gitlabAuthApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L260) +[gitlabAuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L260) -## googleAuth +### googleAuth Provides authentication towards Google APIs and identities @@ -88,18 +106,31 @@ Implemented types: [OAuthApi](./OAuthApi.md), [SessionStateApi](./SessionStateApi.md) ApiRef: -[googleAuthApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L213) +[googleAuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L213) -## identity +### identity Provides access to the identity of the signed in user Implemented type: [IdentityApi](./IdentityApi.md) ApiRef: -[identityApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/IdentityApi.ts#L54) +[identityApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/IdentityApi.ts#L54) -## oauth2 +### microsoftAuth + +Provides authentication towards Microsoft APIs and identities + +Implemented types: [OAuthApi](./OAuthApi.md), +[OpenIdConnectApi](./OpenIdConnectApi.md), +[ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), +[SessionStateApi](./SessionStateApi.md) + +ApiRef: +[microsoftAuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L287) + +### oauth2 Example of how to use oauth2 custom provider @@ -108,18 +139,18 @@ Implemented types: [OAuthApi](./OAuthApi.md), [ProfileInfoApi](./ProfileInfoApi.md), [SessionStateApi](./SessionStateApi.md) ApiRef: -[oauth2ApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L270) +[oauth2ApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L301) -## oauthRequest +### oauthRequest An API for implementing unified OAuth flows in Backstage Implemented type: [OAuthRequestApi](./OAuthRequestApi.md) ApiRef: -[oauthRequestApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130) +[oauthRequestApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130) -## oktaAuth +### oktaAuth Provides authentication towards Okta APIs @@ -130,13 +161,13 @@ Implemented types: [OAuthApi](./OAuthApi.md), [SessionStateApi](./SessionStateApi.md) ApiRef: -[oktaAuthApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L243) +[oktaAuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L243) -## storage +### storage Provides the ability to store data which is unique to the user Implemented type: [StorageApi](./StorageApi.md) ApiRef: -[storageApiRef](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/StorageApi.ts#L68) +[storageApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/StorageApi.ts#L68) diff --git a/docs/reference/utility-apis/SessionStateApi.md b/docs/reference/utility-apis/SessionStateApi.md index 44d329304a..c1821f2381 100644 --- a/docs/reference/utility-apis/SessionStateApi.md +++ b/docs/reference/utility-apis/SessionStateApi.md @@ -1,16 +1,20 @@ # SessionStateApi The SessionStateApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:201](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L201). +[packages/core-api/src/apis/definitions/auth.ts:201](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L201). The following Utility APIs implement this type: +- [auth0AuthApiRef](./README.md#auth0auth) + - [githubAuthApiRef](./README.md#githubauth) - [gitlabAuthApiRef](./README.md#gitlabauth) - [googleAuthApiRef](./README.md#googleauth) +- [microsoftAuthApiRef](./README.md#microsoftauth) + - [oauth2ApiRef](./README.md#oauth2) - [oktaAuthApiRef](./README.md#oktaauth) @@ -52,7 +56,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L53). Referenced by: [sessionState\$](#sessionstate). @@ -71,7 +75,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -87,7 +91,7 @@ export enum SessionState { Defined at -[packages/core-api/src/apis/definitions/auth.ts:192](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/auth.ts#L192). +[packages/core-api/src/apis/definitions/auth.ts:192](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L192). Referenced by: [sessionState\$](#sessionstate). @@ -110,6 +114,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/StorageApi.md b/docs/reference/utility-apis/StorageApi.md index 5bc871e7bf..bee52935da 100644 --- a/docs/reference/utility-apis/StorageApi.md +++ b/docs/reference/utility-apis/StorageApi.md @@ -1,7 +1,7 @@ # StorageApi The StorageApi type is defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/StorageApi.ts#L31). +[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/StorageApi.ts#L31). The following Utility API implements this type: [storageApiRef](./README.md#storage) @@ -79,7 +79,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L53). Referenced by: [observe\$](#observe), [StorageApi](#storageapi). @@ -98,7 +98,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -144,7 +144,7 @@ export interface StorageApi { Defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/StorageApi.ts#L31). +[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/StorageApi.ts#L31). Referenced by: [forBucket](#forbucket). @@ -158,7 +158,7 @@ export type StorageValueChange<T = any> = { Defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:21](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/apis/definitions/StorageApi.ts#L21). +[packages/core-api/src/apis/definitions/StorageApi.ts:21](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/StorageApi.ts#L21). Referenced by: [observe\$](#observe), [StorageApi](#storageapi). @@ -181,6 +181,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/f8780ff32509d0326bc513791ea60846d7614b34/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md deleted file mode 100644 index b5780875b2..0000000000 --- a/docs/tutorials/index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -id: index -title: Overview ---- - -## Coming soon! diff --git a/docs/journey.md b/docs/tutorials/journey.md similarity index 94% rename from docs/journey.md rename to docs/tutorials/journey.md index e38130afb7..24765e8531 100644 --- a/docs/journey.md +++ b/docs/tutorials/journey.md @@ -1,12 +1,16 @@ -# Purpose +--- +id: journey +title: Future developer journey +description: This document describes a possible journey of a future Backstage +--- -This RFC describes a possible journey of a future Backstage plugin developer as -they build a plugin that touches many different aspects of a Backstage. The -story invents many new things that are not part of Backstage today, but are -things that I'm suggesting we should add as long term or north star goals. The -idea is to discuss what parts of the story makes sense to aim for, and what we'd -want to do differently or not at all. The "chapters" are numbered to make it a -bit easier to comment on parts of the story. +> This document describes a possible journey of a **_future_** Backstage plugin +> developer as they build a plugin that touches many different aspects of a +> Backstage. The story invents many new things that are not part of Backstage +> today, but are things that I'm suggesting we should add as long term or north +> star goals. The idea is to discuss what parts of the story makes sense to aim +> for, and what we'd want to do differently or not at all. The "chapters" are +> numbered to make it a bit easier to comment on parts of the story. # The Protagonist diff --git a/docs/verify-links.js b/docs/verify-links.js index a218cde71b..9bf8a67ad8 100755 --- a/docs/verify-links.js +++ b/docs/verify-links.js @@ -53,6 +53,11 @@ async function verifyUrl(basePath, url) { return { url, basePath, problem: 'not-relative' }; } + const staticPath = resolvePath(projectRoot, 'microsite/static', `.${url}`); + if (await fs.pathExists(staticPath)) { + return; + } + path = resolvePath(projectRoot, `.${url}`); } else { path = resolvePath(dirname(resolvePath(projectRoot, basePath)), url); @@ -103,7 +108,9 @@ async function main() { console.log(`Found ${badUrls.length} bad links within repo`); for (const { url, basePath, problem } of badUrls) { if (problem === 'missing') { - console.error(`Unable to reach ${url}, linked from ${basePath}`); + console.error( + `Unable to reach ${url} from root or microsite/static/, linked from ${basePath}`, + ); } else if (problem === 'not-relative') { console.error('Links to /docs/ must be relative'); console.error(` From: ${basePath}`); diff --git a/install/kubernetes/app.yaml b/install/kubernetes/app.yaml index ce284afd31..c54e25d519 100644 --- a/install/kubernetes/app.yaml +++ b/install/kubernetes/app.yaml @@ -18,10 +18,10 @@ spec: component: frontend spec: containers: - - name: app - image: spotify/backstage:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 80 - name: app - protocol: TCP + - name: app + image: spotify/backstage:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + name: app + protocol: TCP diff --git a/install/kubernetes/backend.yaml b/install/kubernetes/backend.yaml index bc82731958..f0695753fd 100644 --- a/install/kubernetes/backend.yaml +++ b/install/kubernetes/backend.yaml @@ -18,10 +18,10 @@ spec: component: backend spec: containers: - - name: backend - image: spotify/backstage-backend:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 7000 - name: backend - protocol: TCP + - name: backend + image: spotify/backstage-backend:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 7000 + name: backend + protocol: TCP diff --git a/install/kubernetes/backstage/Chart.yaml b/install/kubernetes/backstage/Chart.yaml index 23ea41ec41..efc3635a7f 100644 --- a/install/kubernetes/backstage/Chart.yaml +++ b/install/kubernetes/backstage/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v1 -appVersion: "1.0" +appVersion: '1.0' description: A Helm chart for Spotify Backstage name: backstage version: 0.1.1-alpha.12 diff --git a/install/kubernetes/backstage/README.md b/install/kubernetes/backstage/README.md index e8d809c228..fc3d20733b 100644 --- a/install/kubernetes/backstage/README.md +++ b/install/kubernetes/backstage/README.md @@ -23,7 +23,7 @@ | app.resources | Kubernetes Pod resource requests/limits | `{}` | | app.nodeSelector | Node selectors for scheduling app/frontend pods | `{}` | | app.tolerations | Tolerations for scheduling app/frontend pods | `{}` | -| app.affinity | Affinity setttings for scheduling app/frontend pods | `{}` | +| app.affinity | Affinity settings for scheduling app/frontend pods | `{}` | ## Backend Values @@ -48,4 +48,4 @@ | backend.resources | Kubernetes Pod resource requests/limits | `{}` | | backend.nodeSelector | Node selectors for scheduling backend pods | `{}` | | backend.tolerations | Tolerations for scheduling backend pods | `{}` | -| backend.affinity | Affinity setttings for scheduling backend pods | `{}` | +| backend.affinity | Affinity settings for scheduling backend pods | `{}` | diff --git a/install/kubernetes/backstage/values.yaml b/install/kubernetes/backstage/values.yaml index fd4dd66b53..d91808ed28 100644 --- a/install/kubernetes/backstage/values.yaml +++ b/install/kubernetes/backstage/values.yaml @@ -1,12 +1,12 @@ app: enabled: true - nameOverride: "" - fullnameOverride: "" + nameOverride: '' + fullnameOverride: '' replicaCount: 1 serviceAccount: create: false - Name: "" - image: + Name: '' + image: repository: spotify/backstage tag: latest pullPolicy: Always @@ -15,20 +15,23 @@ app: port: 80 ingress: enabled: false - annotations: {} + annotations: + {} # kubernetes.io/ingress.class: "nginx" hosts: - - host: backstage.local - paths: - - / + - host: backstage.local + paths: + - / tls: [] # - secretName: chart-example-tls # hosts: # - chart-example.local imagePullSecrets: [] - podSecurityContext: {} + podSecurityContext: + {} # fsGroup: 2000 - securityContext: {} + securityContext: + {} # capabilities: # drop: # - ALL @@ -48,12 +51,12 @@ app: backend: enabled: false - nameOverride: "" - fullnameOverride: "" + nameOverride: '' + fullnameOverride: '' replicaCount: 1 serviceAccount: create: false - Name: "" + Name: '' image: repository: spotify/backstage-backend tag: latest @@ -63,20 +66,23 @@ backend: port: 7000 ingress: enabled: false - annotations: {} + annotations: + {} # kubernetes.io/ingress.class: "nginx" hosts: - - host: backstage.local - paths: - - /backend + - host: backstage.local + paths: + - /backend tls: [] # - secretName: chart-example-tls # hosts: # - chart-example.local imagePullSecrets: [] - podSecurityContext: {} + podSecurityContext: + {} # fsGroup: 2000 - securityContext: {} + securityContext: + {} # capabilities: # drop: # - ALL diff --git a/install/kubernetes/ingress.yaml b/install/kubernetes/ingress.yaml index d2ecae74c6..f93a814a0c 100644 --- a/install/kubernetes/ingress.yaml +++ b/install/kubernetes/ingress.yaml @@ -7,14 +7,14 @@ metadata: component: ingress spec: rules: - - host: - http: - paths: - - backend: - serviceName: backstage - servicePort: frontend - path: / - - backend: - serviceName: backstage-backend - servicePort: backend - path: /backend + - host: + http: + paths: + - backend: + serviceName: backstage + servicePort: frontend + path: / + - backend: + serviceName: backstage-backend + servicePort: backend + path: /backend diff --git a/install/kubernetes/service.yaml b/install/kubernetes/service.yaml index bbbc3d6e17..4d947b7afc 100644 --- a/install/kubernetes/service.yaml +++ b/install/kubernetes/service.yaml @@ -11,10 +11,10 @@ spec: app: backstage component: frontend ports: - - name: frontend - port: 80 - protocol: TCP - targetPort: app + - name: frontend + port: 80 + protocol: TCP + targetPort: app --- apiVersion: v1 kind: Service @@ -29,7 +29,7 @@ spec: app: backstage component: backend ports: - - name: backend - port: 7000 - protocol: TCP - targetPort: backend + - name: backend + port: 7000 + protocol: TCP + targetPort: backend diff --git a/lerna.json b/lerna.json index 35f84c54d0..63e4701ce3 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "packages": ["packages/*", "plugins/*"], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.1.1-alpha.18" + "version": "0.1.1-alpha.20" } diff --git a/microsite/.gitignore b/microsite/.gitignore new file mode 100644 index 0000000000..f5bcdb3385 --- /dev/null +++ b/microsite/.gitignore @@ -0,0 +1,4 @@ + +# Build output +build +i18n diff --git a/microsite/README copy.md b/microsite/README copy.md deleted file mode 100644 index d7187888a3..0000000000 --- a/microsite/README copy.md +++ /dev/null @@ -1,182 +0,0 @@ -This website was created with [Docusaurus](https://docusaurus.io/). - -# What's In This Document - -- [Get Started in 5 Minutes](#get-started-in-5-minutes) -- [Directory Structure](#directory-structure) -- [Editing Content](#editing-content) -- [Adding Content](#adding-content) -- [Full Documentation](#full-documentation) - -## Directory Structure - -Your project file structure should look something like this - -``` -my-docusaurus/ - docs/ - doc-1.md - doc-2.md - doc-3.md - website/ - blog/ - 2016-3-11-oldest-post.md - 2017-10-24-newest-post.md - core/ - node_modules/ - pages/ - static/ - css/ - img/ - package.json - sidebars.json - siteConfig.js -``` - -# Editing Content - -## Editing an existing docs page - -Edit docs by navigating to `docs/` and editing the corresponding document: - -`docs/doc-to-be-edited.md` - -```markdown ---- -id: page-needs-edit -title: This Doc Needs To Be Edited ---- - -Edit me... -``` - -For more information about docs, click [here](https://docusaurus.io/docs/en/navigation) - -## Editing an existing blog post - -Edit blog posts by navigating to `website/blog` and editing the corresponding post: - -`website/blog/post-to-be-edited.md` - -```markdown ---- -id: post-needs-edit -title: This Blog Post Needs To Be Edited ---- - -Edit me... -``` - -For more information about blog posts, click [here](https://docusaurus.io/docs/en/adding-blog) - -# Adding Content - -## Adding a new docs page to an existing sidebar - -1. Create the doc as a new markdown file in `/docs`, example `docs/newly-created-doc.md`: - -```md ---- -id: newly-created-doc -title: This Doc Needs To Be Edited ---- - -My new content here.. -``` - -1. Refer to that doc's ID in an existing sidebar in `website/sidebars.json`: - -```javascript -// Add newly-created-doc to the Getting Started category of docs -{ - "docs": { - "Getting Started": [ - "quick-start", - "newly-created-doc" // new doc here - ], - ... - }, - ... -} -``` - -For more information about adding new docs, click [here](https://docusaurus.io/docs/en/navigation) - -## Adding a new blog post - -1. Make sure there is a header link to your blog in `website/siteConfig.js`: - -`website/siteConfig.js` - -```javascript -headerLinks: [ - ... - { blog: true, label: 'Blog' }, - ... -] -``` - -2. Create the blog post with the format `YYYY-MM-DD-My-Blog-Post-Title.md` in `website/blog`: - -`website/blog/2018-05-21-New-Blog-Post.md` - -```markdown ---- -author: Frank Li -authorURL: https://twitter.com/foobarbaz -authorFBID: 503283835 -title: New Blog Post ---- - -Lorem Ipsum... -``` - -For more information about blog posts, click [here](https://docusaurus.io/docs/en/adding-blog) - -## Adding items to your site's top navigation bar - -1. Add links to docs, custom pages or external links by editing the headerLinks field of `website/siteConfig.js`: - -`website/siteConfig.js` - -```javascript -{ - headerLinks: [ - ... - /* you can add docs */ - { doc: 'my-examples', label: 'Examples' }, - /* you can add custom pages */ - { page: 'help', label: 'Help' }, - /* you can add external links */ - { href: 'https://github.com/facebook/docusaurus', label: 'GitHub' }, - ... - ], - ... -} -``` - -For more information about the navigation bar, click [here](https://docusaurus.io/docs/en/navigation) - -## Adding custom pages - -1. Docusaurus uses React components to build pages. The components are saved as .js files in `website/pages/en`: -1. If you want your page to show up in your navigation header, you will need to update `website/siteConfig.js` to add to the `headerLinks` element: - -`website/siteConfig.js` - -```javascript -{ - headerLinks: [ - ... - { page: 'my-new-custom-page', label: 'My New Custom Page' }, - ... - ], - ... -} -``` - -For more information about custom pages, click [here](https://docusaurus.io/docs/en/custom-pages). - -# Full Documentation - -Full documentation can be found on the [website](https://docusaurus.io/). diff --git a/microsite/README.md b/microsite/README.md index d7187888a3..d244caa22a 100644 --- a/microsite/README.md +++ b/microsite/README.md @@ -2,12 +2,36 @@ This website was created with [Docusaurus](https://docusaurus.io/). # What's In This Document -- [Get Started in 5 Minutes](#get-started-in-5-minutes) +- [Getting Started](#getting-started) - [Directory Structure](#directory-structure) - [Editing Content](#editing-content) - [Adding Content](#adding-content) - [Full Documentation](#full-documentation) +# Getting Started + +## Installation + +``` +$ yarn install +``` + +## Local Development + +``` +$ yarn start +``` + +This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server. + +## Build + +``` +$ yarn build +``` + +This command generates static content into the `build` directory, which is what will be deployed to GitHub pages from the master branch. + ## Directory Structure Your project file structure should look something like this diff --git a/microsite/blog/2020-03-18-what-is-backstage.md b/microsite/blog/2020-03-18-what-is-backstage.md index 942adc607f..8b0a0fcbe6 100644 --- a/microsite/blog/2020-03-18-what-is-backstage.md +++ b/microsite/blog/2020-03-18-what-is-backstage.md @@ -13,7 +13,7 @@ Two days ago, we released the open source version of [Backstage](https://backsta ## What’s the big infrastructure problem? -As companies grow, their infrastructure systems get messier. Consider a team that wants to deploy something to the cloud. While Spotify has many awesome engineers, not every engineer is well-versed in our chosen cloud-provider tooling. Yet everyone is required to know and understand Terraform, GCP/AWS/Azure CLIs, Gitlab CI, Prometheus, Kubernetes, Docker, various monitoring and alerting tools, and much, much more. Once other resources come into play (databases, queueing, etc.), each engineer requires even more tools and domain-specific knowledge (or “disciplines”), from backend to machine learning, to mobile and data. +As companies grow, their infrastructure systems get messier. Consider a team that wants to deploy something to the cloud. While Spotify has many awesome engineers, not every engineer is well-versed in our chosen cloud-provider tooling. Yet everyone is required to know and understand Terraform, GCP/AWS/Azure CLIs, GitLab CI, Prometheus, Kubernetes, Docker, various monitoring and alerting tools, and much, much more. Once other resources come into play (databases, queueing, etc.), each engineer requires even more tools and domain-specific knowledge (or “disciplines”), from backend to machine learning, to mobile and data. ## What’s the fix? diff --git a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md index 65ee02f6a0..dff567eaf3 100644 --- a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md +++ b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md @@ -34,7 +34,7 @@ You get to take full advantage of a platform that we at Spotify have been using Just run the backstage-cli: ```bash -npx @backstage/cli create-app +npx @backstage/create-app ``` Name your app, and we will create everything you need: @@ -50,7 +50,7 @@ yarn start And you are good to go! 👍 -Read the full documentation on how to [create an app](/docs/getting-started/create-an-app.md) on GitHub. +Read the full documentation on how to [create an app](/docs/getting-started/create-an-app) on GitHub. ## What do I get? (Let's get technical...) diff --git a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md index 6143442782..81d495e1c3 100644 --- a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md +++ b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md @@ -35,11 +35,11 @@ Since the templates can be customized to integrate with your existing infrastruc ### Golden Paths pave the way -You can customize Backstage Software Templates to fit your organization’s standards. Using Go instead of Java? CircleCI instead of Jenkins? Serverless instead of Kubernetes? GCP instead of AWS? [Make your own recipes for any software component](/docs/features/software-templates/adding-templates.md) and your best practices will be baked right in. +You can customize Backstage Software Templates to fit your organization’s standards. Using Go instead of Java? CircleCI instead of Jenkins? Serverless instead of Kubernetes? GCP instead of AWS? [Make your own recipes for any software component](https://backstage.io/docs/features/software-templates/adding-templates) and your best practices will be baked right in. ## Getting started -The sample Software Templates are available under `/create`. If you're setting up Backstage for the first time, follow [Getting Started with Backstage](/docs/getting-started/index.md) and go to `http://localhost:3000/create`. If you’ve already been running Backstage locally, run the command `yarn lerna run mock-data` to load the new sample templates into the Service Catalog first. +The sample Software Templates are available under `/create`. If you're setting up Backstage for the first time, follow [Getting Started with Backstage](https://backstage.io/docs/getting-started/) and go to `http://localhost:3000/create`. ![available-templates](assets/2020-08-05/templates.png) @@ -69,7 +69,7 @@ New components, of course, get added automatically to the Backstage Service Cata ## Define your standards -Backstage ships with four example templates, but since these are likely not the (only) ones you want to promote inside your company, the next step is to add [your own templates](/docs/features/software-templates/adding-templates.md). Using Backstage’s Software Templates feature, it’s easy to help your engineers get started building software with your organization’s best practices built-in. +Backstage ships with four example templates, but since these are likely not the (only) ones you want to promote inside your company, the next step is to add [your own templates](https://backstage.io/docs/features/software-templates/software-templates-index). Using Backstage’s Software Templates feature, it’s easy to help your engineers get started building software with your organization’s best practices built-in. We have learned that one of the keys to getting these standards adopted is to keep an open process. Templates are code. By making it clear to your engineers that you are open to pull requests, and that teams with different needs can add their own templates, you are on the path of striking a good balance between autonomy and standardization. diff --git a/microsite/blog/2020-09-08-announcing-tech-docs.md b/microsite/blog/2020-09-08-announcing-tech-docs.md new file mode 100644 index 0000000000..48b6eeb7cc --- /dev/null +++ b/microsite/blog/2020-09-08-announcing-tech-docs.md @@ -0,0 +1,120 @@ +--- +title: Announcing TechDocs: Spotify’s docs-like-code plugin for Backstage +author: Gary Niemen +authorURL: https://github.com/garyniemen +--- + +Since we [open sourced Backstage](https://backstage.io/blog/2020/03/16/announcing-backstage), one of the most requested features has been for a technical documentation plugin. Well, good news. The first open source version of TechDocs is here. Now let’s start collaborating and making it better, together. + + + + + +Internally, we call it TechDocs. It’s the most used plugin at Spotify by far — accounting for about 20% of our Backstage traffic (even though it is just one of 130+ plugins). Its popularity is evidence of something simple: We made documentation so easy to create, find, and use — people actually use it. + +We are quite sure the main reason for the success of TechDocs is our docs-like-code approach — engineers write their technical documentation in Markdown files that live together with the code. During CI, a documentation site is created using MkDocs, and all sites are rendered centrally in a Backstage plugin. On top of the static documentation, we incorporate additional metadata about the documentation site — such as owner, open GitHub Issues, Slack support channel, and Stack Overflow Enterprise tags. + +![available-templates](assets/announcing-techdocs/docs-in-backstage.png) + +But this is just one way to do it. Today we’re most excited for what the open version of TechDocs can become. + +## Okay, let’s start collaborating + +If you go to [GitHub](https://github.com/spotify/backstage/tree/master/plugins) now, you’ll find everything you need to start collaborating with us to build out the docs-like-code Backstage plugin — we’ll call it TechDocs in the open as well. + +You’ll find the code in [techdocs](https://github.com/spotify/backstage/tree/master/plugins/techdocs) (frontend) and [techdocs-backend](https://github.com/spotify/backstage/tree/master/plugins/techdocs-backend). (There are also two separate packages [techdocs-cli](https://github.com/spotify/backstage/tree/master/packages/techdocs-cli) and [techdocs-container](https://github.com/spotify/backstage/tree/master/packages/techdocs-container).) + +You’ll find issues to work on in the [issues queue](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+label%3A%22docs-like-code%22+label%3A%22help+wanted%22), typically starting with TechDocs: and labeled with docs-like-code, some labeled good first issue. Feel free to add your own issues, of course. + +![available-templates](assets/announcing-techdocs/github-issues.png) + +What we have on GitHub so far is a first iteration of TechDocs that you can use end-to-end — in other words, from docs written in Markdown in GitHub to a published site on Backstage. + +More specifically, with this first iteration, you can: + +- Run TechDocs locally and read documentation. +- Configure your entity (e.g. service, website) so that Backstage builds your documentation and serves it in TechDocs. Documentation is displayed on the Docs tab in the Service Catalog and on its own page. +- Get documentation set up for free in your project when you create a new component out of one of the non-experimental templates (labeled with recommended). If you are looking for a standalone documentation project, use the docs-template. +- Choose your own storage solution for the documentation. +- Define your own API to interface with your documentation solution. + +For a full overview, including getting started instructions, check out our [TechDocs Documentation](https://backstage.io/docs/features/techdocs/techdocs-overview). + +But before you go there, let me tell you a bit about the TechDocs story — and why we believe TechDocs is such a powerful yet simple solution for great documentation. + +## The TechDocs story + +Here is the TechDocs story. It’s not an uncommon one (we have learned from many other companies). + +About a year and a half ago, we conducted a company-wide productivity survey. The third largest problem according to all our engineers? Not being able to find the technical information they needed to do their work. And it’s not surprising. There was no standard way to produce and consume technical documentation, so teams were going their own way — using Confluence, Google Docs, README files, custom built websites, GitHub Pages, and so on and on. And those searching for information were left to hunt for it in all those different places until they found what they were looking for (if they ever did). Worse, if you did happen to find the documentation that you needed, there was no way to know whether the information was up-to-date or correct. In other words, there was no way to know whether you could trust what you found. We did have technical writers at the company, but they were mostly scattered across the company solving documentation problems within their own particular domain. + +So this is the fertile soil on which TechDocs was built. + +After a Hack Week implementation attracted interest from high up in the company, we formed a cross-functional team made up of technical writers and engineers with the mission to solve internal technical documentation at Spotify. And we started to build TechDocs. We went for a docs-like-code approach, fiercely optimizing for engineers and engineering workflows. We also went for an opinionated approach, telling everybody: This is the standard way to do technical documentation at Spotify. The sense was that engineers appreciated a documentation solution that was in line with their workflow and, after all the frustration of multiple tools, were relieved to be told “this is the way to do it”. + +For more information about this journey, take a look at my 20-minute talk from DevRelCon London from last December: [The Hero’s Journey: How we are solving internal technical documentation at Spotify](https://www.linkedin.com/posts/garyniemen_how-we-are-solving-internal-technical-documentation-activity-6646078605594030080-4L31). + +## Key problem areas that we are solving + +We have come a long way, fast — both in our implementation and in our thinking. Here are some of the key problem areas that we are addressing. Note that they are in various stages of implementation, and we won’t be able to release everything within our minimum plugin. In fact, see this as an appetite taster. What we hope is that we can build together. + +### Stuck to unstuck + +Very early on, we decided that the main problem we were trying to solve was to help engineers (when using technical documentation) go from stuck to unstuck, and fast. This became our guiding principle. Is what we are building helping engineers get unstuck faster? From this, it follows that we need to promote quality documentation on the one hand, and provide a high level of discoverability on the other. One without the other is not going to cut it. + +### Feedback loops + +What we want to build is a thriving community of technical documentation creators, contributors, and readers. We want this because, we believe, this is the way to drive up the quality of the documentation. More readers, more feedback, more doc updates. And driving up the quality of the corpus of technical documentation leads to trust which in turn leads to more engagement and, hence, more of a thriving community. + +To get this working, we recognised that we need to remove ‘friction from the system’ — we need to build in efficient feedback loops. For example, help engineers get their doc site up by providing documentation improvement hints and build information as close as possible to where they are already working. And for readers, make it easy to give feedback. And then for doc site owners, ensure that they are notified when there is feedback and incentivised to make the fix. + +![available-templates](assets/announcing-techdocs/feedback-loop1.png) + +![available-templates](assets/announcing-techdocs/feedback-loop2.png) + +### Trust + +How do I know whether to trust this piece of documentation? This is a question we want to be able to answer for those using technical documentation in Backstage. It’s not an easy nut to crack. It is almost, one could say, the hard problem of technical documentation. For example, some might say ‘last updated’ is a key factor. But what about stable, good quality documentation that has no need to be updated? What about page views? Yes, this is a good sign that the documentation is being found and viewed, but it doesn’t say anything about whether the documentation can be trusted. How about a button: Did this documentation help? This is good, but will people use it? Will we get enough data to show trust? We have lofty ambitions of one day providing a trust score on the doc site informed by a super-intelligent algorithm. But we are not there yet. For now, we have landed on surfacing when the documentation was last updated, top five contributors, the support channel, owning team, and number of open GitHub Issues. But going forward we are definitely up for solving the hard problem. We think there’s much more work to be done here and look forward to seeing ideas from the community. + +### Discoverability and search + +How to find stuff? That is another big question. As mentioned above, it’s all well and good having quality documentation, but it’s no use whatsoever if you can’t find it. If you know what you are looking for, then you can use a search engine. If you don’t know what you are looking for, then you are going to need more — like a well designed information architecture, a user friendly browse experience, and even intelligent suggestions based on your role and what you have searched for previously. + +In this problem area, we made use of Elasticsearch, the open source search engine that was already being used in Backstage, to implement documentation search across all documentation sites and per documentation site. In terms of discoverability, we implemented a documentation home page in Backstage that surfaces Spotify’s most important documents and uses metrics to list the company’s most used doc sites as well as the documentation equivalent of a “your daily mix” playlist. + +![available-templates](assets/announcing-techdocs/discover1.png) + +![available-templates](assets/announcing-techdocs/discover2.png) + +There is much more to do in the area of discoverability and search. + +### Use case variations + +The standard use case for TechDocs is: One component in Backstage equals one GitHub repository, equals one doc site. This use case comes in two flavours: the repository is a code repository with docs or a docs-only repository. Then, to meet the needs of one large part of the Spotify engineering organisation that uses monorepos (multi-component repositories), we added a third use case. We built an MkDocs plugin that enabled doc site creators to include documentation from doc folders in other parts of the repository. So this use case is: One main component in Backstage equals a monorepo with distributed documentation, equals one doc site. + +These three use cases satisfy most of the needs, but we have had plenty of requests for additional use cases, for example, the ability to create multiple doc sites from a multi-component repository and the ability to create one doc site from documentation in multiple repositories. + +### Metrics + +There are many good arguments for standardizing the way that technical documentation is produced and consumed. One of them is metrics. If we have one way of producing technical documentation (in our case, GitHub Enterprise) and one place where it shows up (in our case, Backstage), we are in a strong position to build up metrics that help all the various stakeholders — for example, us building TechDocs, teams creating documentation sites, and engineers trying to get unstuck. Just imagine how much harder this would be if technical documentation was produced and consumed in a plethora of places, such as Confluence, Google Docs, README files, custom web sites, and GitHub Pages. + +One thing we have recently completed is a Manage page in Backstage for doc site owners. Here teams can see all the documentation that they own, the number of GitHub Issues per doc site or page, and last updated. We have also built a large dashboard using the open source analytics software Redash to inform our own product development process. + +![available-templates](assets/announcing-techdocs/metrics.png) + +Again, there is a lot more that can be done in the area of metrics. Did I mention the trust score? + +### Code-like-docs + +Code-like-docs, what? Okay, it’s just my little play on words. This is what I mean. One request that we keep getting is to be able to have code in the documentation fetched from and in sync with code in GitHub. In this way, you can avoid code in the documentation going stale. MkDocs does have an extension for this — but it has some limitations. For example, the code has to be in the /docs folder with the Markdown files. We are working on developing a wider and more flexible solution. + +### Golden Paths + +At Spotify, we have the concept of [Golden Paths](https://engineering.atspotify.com/2020/08/17/how-we-use-golden-paths-to-solve-fragmentation-in-our-software-ecosystem/) — one for each engineering discipline. My favourite definition of Golden Path is that it is the “opinionated and supported path”. Each Golden Path has an accompanying Golden Path tutorial that walks you through the opinionated and supported path. + +The Golden Path tutorials are Spotify’s most used and important documents and have shown themselves to be the most challenging to manage within a docs-like-code environment. One reason for this is that they are long, divided into many parts, and ownership is typically spread among many different teams. We have had to make use of GitHub codeowners to handle ownership and had to create datasets and data pipelines to be able to attach GitHub Issues to the specific parts or files that a team owns. Another challenge of the Golden Path tutorials is that parts are often dependent on other parts. We are just starting to look into how we can solve these dependency challenges in order to remove friction for engineers writing tutorial documentation. + +--- + +So that’s it for now. As you can see, we have come a long way AND there is much more to do. We are looking forward to continuing our docs-like-code journey out in the open with new, enthusiastic technical documentation friends. diff --git a/microsite/blog/assets/announcing-techdocs/discover1.png b/microsite/blog/assets/announcing-techdocs/discover1.png new file mode 100644 index 0000000000..5b23e64b43 Binary files /dev/null and b/microsite/blog/assets/announcing-techdocs/discover1.png differ diff --git a/microsite/blog/assets/announcing-techdocs/discover2.png b/microsite/blog/assets/announcing-techdocs/discover2.png new file mode 100644 index 0000000000..3737ee68db Binary files /dev/null and b/microsite/blog/assets/announcing-techdocs/discover2.png differ diff --git a/microsite/blog/assets/announcing-techdocs/docs-in-backstage.png b/microsite/blog/assets/announcing-techdocs/docs-in-backstage.png new file mode 100644 index 0000000000..f3b724ff4f Binary files /dev/null and b/microsite/blog/assets/announcing-techdocs/docs-in-backstage.png differ diff --git a/microsite/blog/assets/announcing-techdocs/feedback-loop1.png b/microsite/blog/assets/announcing-techdocs/feedback-loop1.png new file mode 100644 index 0000000000..4e2d0de5df Binary files /dev/null and b/microsite/blog/assets/announcing-techdocs/feedback-loop1.png differ diff --git a/microsite/blog/assets/announcing-techdocs/feedback-loop2.png b/microsite/blog/assets/announcing-techdocs/feedback-loop2.png new file mode 100644 index 0000000000..516780e3ac Binary files /dev/null and b/microsite/blog/assets/announcing-techdocs/feedback-loop2.png differ diff --git a/microsite/blog/assets/announcing-techdocs/github-issues.png b/microsite/blog/assets/announcing-techdocs/github-issues.png new file mode 100644 index 0000000000..3d5f3465c0 Binary files /dev/null and b/microsite/blog/assets/announcing-techdocs/github-issues.png differ diff --git a/microsite/blog/assets/announcing-techdocs/metrics.png b/microsite/blog/assets/announcing-techdocs/metrics.png new file mode 100644 index 0000000000..b332a65b0d Binary files /dev/null and b/microsite/blog/assets/announcing-techdocs/metrics.png differ diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index c44b8196a6..761862c1fc 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -32,41 +32,25 @@ class Footer extends React.Component {
More
diff --git a/microsite/data/plugins/api-docs.yaml b/microsite/data/plugins/api-docs.yaml new file mode 100644 index 0000000000..babcbf74f1 --- /dev/null +++ b/microsite/data/plugins/api-docs.yaml @@ -0,0 +1,9 @@ +--- +title: API Docs +author: SDA SE +authorUrl: https://sda.se/ +category: Discovery +description: Components to discover and display API entities as an extension to the catalog plugin. +documentation: https://github.com/spotify/backstage/blob/master/plugins/api-docs/README.md +iconUrl: https://thecoders.io/wp-content/uploads/2019/11/tech-swagger.svg +npmPackageName: '@backstage/plugin-api-docs' diff --git a/microsite/data/plugins/circleci.yaml b/microsite/data/plugins/circleci.yaml new file mode 100644 index 0000000000..5f7b4b08e2 --- /dev/null +++ b/microsite/data/plugins/circleci.yaml @@ -0,0 +1,12 @@ +--- +title: CircleCI +author: Spotify +authorUrl: https://github.com/spotify +category: CI +description: Automate your development process with CI hosted in the cloud or on a private server. +documentation: https://github.com/spotify/backstage/tree/master/plugins/circleci +iconUrl: https://www.saaves.com/storage/brochure/logo-circleci-icon1583764538.png +npmPackageName: '@backstage/plugin-circleci' +tags: + - ci + - cd diff --git a/microsite/data/plugins/github-actions.yaml b/microsite/data/plugins/github-actions.yaml new file mode 100644 index 0000000000..062d0fff61 --- /dev/null +++ b/microsite/data/plugins/github-actions.yaml @@ -0,0 +1,13 @@ +--- +title: GitHub Actions +author: Spotify +authorUrl: https://github.com/spotify +category: CI +description: GitHub Actions makes it easy to automate all your software workflows, now with world-class CI/CD. Build, test, and deploy your code right from GitHub. +documentation: https://github.com/spotify/backstage/tree/master/plugins/github-actions +iconUrl: https://avatars2.githubusercontent.com/u/44036562?s=400&v=4 +npmPackageName: '@backstage/plugin-github-actions' +tags: + - ci + - cd + - github diff --git a/microsite/data/plugins/github-pull-requests.yaml b/microsite/data/plugins/github-pull-requests.yaml new file mode 100644 index 0000000000..6479452c8e --- /dev/null +++ b/microsite/data/plugins/github-pull-requests.yaml @@ -0,0 +1,9 @@ +--- +title: GitHub Pull Requests +author: roadie.io +authorUrl: https://roadie.io/ +category: CI +description: View GitHub pull requests for your service in Backstage. +documentation: https://roadie.io/backstage/plugins/github-pull-requests +iconUrl: https://roadie.io/static/7f13bb8d861d8dedc5112fb939d215f9/351f2/GitHub-Mark-Light-120px-plus.png +npmPackageName: '@roadiehq/backstage-plugin-github-pull-requests' diff --git a/microsite/data/plugins/gitops-cluster.yaml b/microsite/data/plugins/gitops-cluster.yaml new file mode 100644 index 0000000000..6f8ab6b097 --- /dev/null +++ b/microsite/data/plugins/gitops-cluster.yaml @@ -0,0 +1,14 @@ +--- +title: GitOps Clusters +author: Weaveworks +authorUrl: https://www.weave.works/ +category: Kubernetes +description: Create GitOps-managed Kubernetes clusters. Currently, it supports provisioning EKS clusters on GitHub via GitHub Actions. +documentation: https://github.com/spotify/backstage/tree/master/plugins/gitops-profiles +iconUrl: https://res-5.cloudinary.com/crunchbase-production/image/upload/c_lpad,h_256,w_256,f_auto,q_auto:eco/v1462316670/i9d3delzvx1erzjhmcws.png +npmPackageName: '@backstage/plugin-gitops-profiles' +tags: + - kubernetes + - gitops + - github + - eks diff --git a/microsite/data/plugins/graphiql.yaml b/microsite/data/plugins/graphiql.yaml new file mode 100644 index 0000000000..e997360b04 --- /dev/null +++ b/microsite/data/plugins/graphiql.yaml @@ -0,0 +1,12 @@ +--- +title: GraphiQL +author: Spotify +authorUrl: https://github.com/spotify +category: Debugging +description: Integrates GraphiQL as a tool to browse GraphQL API endpoints inside Backstage. +documentation: https://github.com/spotify/backstage/tree/master/plugins/graphiql +iconUrl: https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/GraphQL_Logo.svg/1024px-GraphQL_Logo.svg.png +npmPackageName: '@backstage/plugin-graphiql' +tags: + - graphql + - graphiql diff --git a/microsite/data/plugins/jenkins.yaml b/microsite/data/plugins/jenkins.yaml new file mode 100644 index 0000000000..78b3616595 --- /dev/null +++ b/microsite/data/plugins/jenkins.yaml @@ -0,0 +1,12 @@ +--- +title: Jenkins +author: '@timja' +authorUrl: https://github.com/timja +category: CI +description: Jenkins offers a simple way to set up a continuous integration and continuous delivery environment. +documentation: https://github.com/spotify/backstage/tree/master/plugins/jenkins +iconUrl: https://img.icons8.com/color/1600/jenkins.png +npmPackageName: '@backstage/plugin-jenkins' +tags: + - ci + - cd diff --git a/microsite/data/plugins/lighthouse.yaml b/microsite/data/plugins/lighthouse.yaml new file mode 100644 index 0000000000..0819455ed2 --- /dev/null +++ b/microsite/data/plugins/lighthouse.yaml @@ -0,0 +1,14 @@ +--- +title: Lighthouse +author: Spotify +authorUrl: https://github.com/spotify +category: Accessibility +description: Google's Lighthouse tool is a great resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your website. +documentation: https://github.com/spotify/backstage/tree/master/plugins/lighthouse +iconUrl: https://seeklogo.com/images/G/google-lighthouse-logo-1C7FA08580-seeklogo.com.png +npmPackageName: '@backstage/plugin-lighthouse' +tags: + - web + - seo + - accessibility + - performance diff --git a/microsite/data/plugins/new-relic.yaml b/microsite/data/plugins/new-relic.yaml new file mode 100644 index 0000000000..e3ddf18652 --- /dev/null +++ b/microsite/data/plugins/new-relic.yaml @@ -0,0 +1,14 @@ +--- +title: New Relic +author: '@timwheelercom' +authorUrl: https://github.com/timwheelercom +category: Monitoring +description: Observability platform built to help engineers create and monitor their software. +documentation: https://github.com/spotify/backstage/tree/master/plugins/newrelic +iconUrl: https://www.mulesoft.com/sites/default/files/2018-10/New_relic.png +npmPackageName: '@backstage/plugin-newrelic' +tags: + - performance + - monitoring + - errors + - alerting diff --git a/microsite/data/plugins/rollbar.yaml b/microsite/data/plugins/rollbar.yaml new file mode 100644 index 0000000000..8ef3d8d66a --- /dev/null +++ b/microsite/data/plugins/rollbar.yaml @@ -0,0 +1,9 @@ +--- +title: Rollbar +author: '@andrewthauer' +authorUrl: https://github.com/andrewthauer +category: Monitoring +description: View Rollbar errors for your services in Backstage. +documentation: https://github.com/spotify/backstage/tree/master/plugins/rollbar +iconUrl: https://rollbar.com/assets/media/rollbar-mark-color.png +npmPackageName: '@backstage/plugin-rollbar' diff --git a/microsite/data/plugins/sentry.yaml b/microsite/data/plugins/sentry.yaml new file mode 100644 index 0000000000..8da488f727 --- /dev/null +++ b/microsite/data/plugins/sentry.yaml @@ -0,0 +1,9 @@ +--- +title: Sentry +author: Spotify +authorUrl: https://github.com/spotify +category: Monitoring +description: View Sentry issues in Backstage. +documentation: https://github.com/spotify/backstage/tree/master/plugins/sentry +iconUrl: https://sentry-brand.storage.googleapis.com/sentry-glyph-white.png +npmPackageName: '@backstage/plugin-sentry' diff --git a/microsite/data/plugins/tech-radar.yaml b/microsite/data/plugins/tech-radar.yaml new file mode 100644 index 0000000000..b1d4f5cc17 --- /dev/null +++ b/microsite/data/plugins/tech-radar.yaml @@ -0,0 +1,9 @@ +--- +title: Tech Radar +author: Spotify +authorUrl: https://github.com/spotify +category: Discovery +description: Visualize the your company's official guidelines of different areas of software development. +documentation: https://github.com/spotify/backstage/tree/master/plugins/tech-radar +iconUrl: https://www.materialui.co/materialIcons/action/track_changes_white_192x192.png +npmPackageName: '@backstage/plugin-tech-radar' diff --git a/microsite/data/plugins/travis-ci.yaml b/microsite/data/plugins/travis-ci.yaml new file mode 100644 index 0000000000..520b884c20 --- /dev/null +++ b/microsite/data/plugins/travis-ci.yaml @@ -0,0 +1,9 @@ +--- +title: Travis CI +author: roadie.io +authorUrl: https://roadie.io/ +category: CI +description: View Travis CI builds for your service in Backstage. +documentation: https://roadie.io/backstage/plugins/travis-ci +iconUrl: https://roadie.io/static/af2941eaf0af675facb281d566f42e14/45f2b/travis-ci-mascot-200x200.png +npmPackageName: '@roadiehq/backstage-plugin-travis-ci' diff --git a/microsite/i18n/en.json b/microsite/i18n/en.json deleted file mode 100644 index af891426db..0000000000 --- a/microsite/i18n/en.json +++ /dev/null @@ -1,312 +0,0 @@ -{ - "_comment": "This file is auto-generated by write-translations.js", - "localized-strings": { - "next": "Next", - "previous": "Previous", - "tagline": "An open platform for building developer portals", - "docs": { - "api/backend": { - "title": "Backend" - }, - "api/utility-apis": { - "title": "Utility APIs" - }, - "architecture-decisions/adrs-adr001": { - "title": "ADR001: Architecture Decision Record (ADR) log", - "sidebar_label": "ADR001" - }, - "architecture-decisions/adrs-adr002": { - "title": "ADR002: Default Software Catalog File Format", - "sidebar_label": "ADR002" - }, - "architecture-decisions/adrs-adr003": { - "title": "ADR003: Avoid Default Exports and Prefer Named Exports", - "sidebar_label": "ADR003" - }, - "architecture-decisions/adrs-adr004": { - "title": "ADR004: Module Export Structure", - "sidebar_label": "ADR004" - }, - "architecture-decisions/adrs-adr005": { - "title": "ADR005: Catalog Core Entities", - "sidebar_label": "ADR005" - }, - "architecture-decisions/adrs-adr006": { - "title": "ADR006: Avoid React.FC and React.SFC", - "sidebar_label": "ADR006" - }, - "architecture-decisions/adrs-adr007": { - "title": "ADR007: Use MSW to mock http requests", - "sidebar_label": "ADR007" - }, - "architecture-decisions/adrs-adr008": { - "title": "ADR008: Default Catalog File Name", - "sidebar_label": "ADR008" - }, - "architecture-decisions/adrs-overview": { - "title": "Architecture Decision Records (ADR)", - "sidebar_label": "Overview" - }, - "auth/add-auth-provider": { - "title": "Adding authentication providers" - }, - "auth/auth-backend": { - "title": "Auth backend" - }, - "auth/glossary": { - "title": "Glossary" - }, - "auth/index": { - "title": "User Authentication and Authorization in Backstage" - }, - "auth/oauth": { - "title": "OAuth and OpenID Connect" - }, - "conf/defining": { - "title": "Defining Configuration for your Plugin" - }, - "conf/index": { - "title": "Static Configuration in Backstage" - }, - "conf/reading": { - "title": "Reading Backstage Configuration" - }, - "conf/writing": { - "title": "Writing Backstage Configuration Files" - }, - "dls/contributing-to-storybook": { - "title": "Contributing to Storybook" - }, - "dls/design": { - "title": "Design" - }, - "dls/figma": { - "title": "Figma" - }, - "FAQ": { - "title": "FAQ" - }, - "features/software-catalog/software-catalog-api": { - "title": "API" - }, - "features/software-catalog/descriptor-format": { - "title": "Descriptor Format of Catalog Entities", - "sidebar_label": "YAML File Format" - }, - "features/software-catalog/extending-the-model": { - "title": "Extending the model" - }, - "features/software-catalog/external-integrations": { - "title": "External integrations" - }, - "features/software-catalog/software-catalog-overview": { - "title": "Backstage Service Catalog (alpha)" - }, - "features/software-catalog/installation": { - "title": "features/software-catalog/installation" - }, - "features/software-catalog/system-model": { - "title": "System Model" - }, - "features/software-templates/adding-templates": { - "title": "Adding your own Templates" - }, - "features/software-templates/extending/extending-preparer": { - "title": "Create your own Preparer" - }, - "features/software-templates/extending/extending-publisher": { - "title": "Create your own Publisher" - }, - "features/software-templates/extending/extending-templater": { - "title": "Creating your own Templater" - }, - "features/software-templates/extending/extending-index": { - "title": "Extending the Scaffolder" - }, - "features/software-templates/software-templates-index": { - "title": "Software Templates" - }, - "features/software-templates/installation": { - "title": "features/software-templates/installation" - }, - "features/techdocs/concepts": { - "title": "Concepts" - }, - "features/techdocs/creating-and-publishing": { - "title": "Creating and publishing your docs", - "sidebar_label": "Creating and Publishing Documentation" - }, - "features/techdocs/faqs": { - "title": "TechDocs FAQ", - "sidebar_label": "FAQ" - }, - "features/techdocs/getting-started": { - "title": "Getting Started" - }, - "features/techdocs/techdocs-overview": { - "title": "TechDocs Documentation", - "sidebar_label": "Overview" - }, - "getting-started/app-custom-theme": { - "title": "Customize the look-and-feel of your App" - }, - "getting-started/configure-app-with-plugins": { - "title": "Configuring App with plugins" - }, - "getting-started/create-an-app": { - "title": "Create an App" - }, - "getting-started/deployment-k8s": { - "title": "Kubernetes" - }, - "getting-started/deployment-other": { - "title": "Other" - }, - "getting-started/development-environment": { - "title": "Development Environment" - }, - "getting-started/index": { - "title": "Running Backstage Locally" - }, - "getting-started/installation": { - "title": "Installation" - }, - "journey": { - "title": "journey" - }, - "overview/architecture-overview": { - "title": "Architecture overview" - }, - "overview/architecture-terminology": { - "title": "Architecture terminology" - }, - "overview/roadmap": { - "title": "Project roadmap" - }, - "overview/support": { - "title": "Support and community" - }, - "overview/what-is-backstage": { - "title": "What is Backstage?" - }, - "plugins/backend-plugin": { - "title": "Backend plugin" - }, - "plugins/call-existing-api": { - "title": "Call existing API" - }, - "plugins/create-a-plugin": { - "title": "Create a Backstage Plugin" - }, - "plugins/existing-plugins": { - "title": "Existing plugins" - }, - "plugins/index": { - "title": "Intro" - }, - "plugins/plugin-development": { - "title": "Plugin Development in Backstage" - }, - "plugins/proxying": { - "title": "Proxying" - }, - "plugins/publish-private": { - "title": "Publish private" - }, - "plugins/publishing": { - "title": "Publishing" - }, - "plugins/structure-of-a-plugin": { - "title": "Structure of a Plugin" - }, - "plugins/testing": { - "title": "Testing with Jest" - }, - "README": { - "title": "README" - }, - "reference/createPlugin-feature-flags": { - "title": "createPlugin - feature flags" - }, - "reference/createPlugin-router": { - "title": "createPlugin - router" - }, - "reference/createPlugin": { - "title": "createPlugin" - }, - "reference/utility-apis/AlertApi": { - "title": "reference/utility-apis/AlertApi" - }, - "reference/utility-apis/AppThemeApi": { - "title": "reference/utility-apis/AppThemeApi" - }, - "reference/utility-apis/BackstageIdentityApi": { - "title": "reference/utility-apis/BackstageIdentityApi" - }, - "reference/utility-apis/Config": { - "title": "reference/utility-apis/Config" - }, - "reference/utility-apis/ErrorApi": { - "title": "reference/utility-apis/ErrorApi" - }, - "reference/utility-apis/FeatureFlagsApi": { - "title": "reference/utility-apis/FeatureFlagsApi" - }, - "reference/utility-apis/IdentityApi": { - "title": "reference/utility-apis/IdentityApi" - }, - "reference/utility-apis/OAuthApi": { - "title": "reference/utility-apis/OAuthApi" - }, - "reference/utility-apis/OAuthRequestApi": { - "title": "reference/utility-apis/OAuthRequestApi" - }, - "reference/utility-apis/OpenIdConnectApi": { - "title": "reference/utility-apis/OpenIdConnectApi" - }, - "reference/utility-apis/ProfileInfoApi": { - "title": "reference/utility-apis/ProfileInfoApi" - }, - "reference/utility-apis/README": { - "title": "Utility API References" - }, - "reference/utility-apis/SessionStateApi": { - "title": "reference/utility-apis/SessionStateApi" - }, - "reference/utility-apis/StorageApi": { - "title": "reference/utility-apis/StorageApi" - }, - "tutorials/index": { - "title": "Overview" - } - }, - "links": { - "GitHub": "GitHub", - "Docs": "Docs", - "Blog": "Blog", - "Demos": "Demos", - "The Spotify story": "The Spotify story", - "Newsletter": "Newsletter" - }, - "categories": { - "Overview": "Overview", - "Getting Started": "Getting Started", - "Features": "Features", - "Plugins": "Plugins", - "Configuration": "Configuration", - "Auth and identity": "Auth and identity", - "Designing for Backstage": "Designing for Backstage", - "API references": "API references", - "Tutorials": "Tutorials", - "Architecture Decision Records (ADRs)": "Architecture Decision Records (ADRs)", - "Contribute": "Contribute", - "Support": "Support", - "FAQ": "FAQ" - } - }, - "pages-strings": { - "Help Translate|recruit community translators for your project": "Help Translate", - "Edit this Doc|recruitment message asking to edit the doc source": "Edit", - "Translate this Doc|recruitment message asking to translate the docs": "Translate" - } -} diff --git a/microsite/package.json b/microsite/package.json index e6fbd1a087..734f1e4d49 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -1,18 +1,19 @@ { - "version": "1.0.0", + "version": "0.0.0", "name": "backstage-microsite", "license": "Apache-2.0", "private": true, "scripts": { "examples": "docusaurus-examples", "start": "docusaurus-start", - "build": "docusaurus-build && cp static/img/*.svg build/backstage/img/", + "build": "docusaurus-build", "publish-gh-pages": "docusaurus-publish", "write-translations": "docusaurus-write-translations", "version": "docusaurus-version", "rename-version": "docusaurus-rename-version" }, "devDependencies": { - "docusaurus": "^1.14.4" + "docusaurus": "^2.0.0-alpha.64", + "js-yaml": "^3.14.0" } } diff --git a/microsite/pages/en/background.js b/microsite/pages/en/background.js deleted file mode 100644 index 70a15421ab..0000000000 --- a/microsite/pages/en/background.js +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Copyright (c) 2017-present, Facebook, Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -const React = require('react'); -const Components = require(`${process.cwd()}/core/Components.js`); -const Block = Components.Block; -const Breakpoint = Components.Breakpoint; - -const Background = props => { - const { config: siteConfig } = props; - const { baseUrl } = siteConfig; - return ( -
- - - The Spotify Story - - - Backstage was born out of necessity at Spotify. We found that as - we grew, our infrastructure was becoming more fragmented, our - engineers less productive.{' '} - - - - Instead of building and testing code, teams were spending more - time looking for the right information just to get started. - “Where’s the API for that service we’re all supposed to be using?” - “What version of that framework is everyone on?” “This service - isn’t responding, who owns it?” “I can’t find documentation for - anything!”{' '} - - - - - - - One place for everything. Accessible to everyone. - - - - } - > - - Context switching and cognitive overload were dragging engineers - down, day by day. We needed to make it easier for our engineers to - do their work without having to become an expert in every aspect - of infrastructure tooling. - - - - Our idea was to centralize and simplify end-to-end software - development with an abstraction layer that sits on top of all of - our infrastructure and developer tooling. That’s Backstage. - - - - It’s a developer portal powered by a centralized service catalog — - with a plugin architecture that makes it endlessly extensible and - customizable. - - - - Manage all your services, software, tooling, and testing in - Backstage. Start building a new microservice using an automated - template in Backstage. Create, maintain, and find the - documentation for all that software in Backstage.{' '} - - - - One place for everything. Accessible to everyone. - - - - Explore Features - - - } - > - - - - - - - - - One place for everything. Accessible to everyone. - - - Explore Features - - - - } - > - -
-
- ); -}; - -module.exports = Background; diff --git a/microsite/pages/en/demos.js b/microsite/pages/en/demos.js index 9f9dcec819..d891e05613 100644 --- a/microsite/pages/en/demos.js +++ b/microsite/pages/en/demos.js @@ -78,6 +78,41 @@ const Background = props => { + + + + + Make documentation easy + + + Documentation! Everyone needs it, no one wants to create it, and + no one can ever find it. Backstage follows a “docs like code” + approach: you write documentation in Markdown files right + alongside your code. This makes documentation easier to create, + maintain, find — and, you know, actually use. This demo video + showcases Spotify’s internal version of TechDocs. Learn more about{' '} + + TechDocs + + . + + + Watch now + + + + + + + + diff --git a/microsite/pages/en/docs.js b/microsite/pages/en/docs.js new file mode 100644 index 0000000000..a8bda18f88 --- /dev/null +++ b/microsite/pages/en/docs.js @@ -0,0 +1,12 @@ +const React = require('react'); +const Redirect = require('../../core/Redirect.js'); + +const siteConfig = require(process.cwd() + '/siteConfig.js'); + +function Docs() { + return ( + + ); +} + +module.exports = Docs; diff --git a/microsite/pages/en/index.js b/microsite/pages/en/index.js index d140a2036b..f595032bd0 100644 --- a/microsite/pages/en/index.js +++ b/microsite/pages/en/index.js @@ -296,9 +296,7 @@ class Index extends React.Component { src={`${baseUrl}animations/backstage-techdocs-icon-1.gif`} /> - - Backstage TechDocs (Coming Soon) - + Backstage TechDocs Docs like code + + + + } /> - Subscribe to our newsletter - - TechDocs is our most used feature at Spotify. Be the first to know - when{' '} - - the open source version - {' '} - ships. - + Learn more about TechDocs - Subscribe + Docs @@ -445,12 +445,8 @@ class Index extends React.Component { Share with the community - Building{' '} - - open source plugins - {' '} - contributes to the entire Backstage ecosystem, which benefits - everyone + Building open source plugins contributes + to the entire Backstage ecosystem, which benefits everyone @@ -462,7 +458,7 @@ class Index extends React.Component { Build a plugin - + Contribute diff --git a/microsite/pages/en/plugins.js b/microsite/pages/en/plugins.js new file mode 100644 index 0000000000..0ef8791970 --- /dev/null +++ b/microsite/pages/en/plugins.js @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2017-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +const fs = require('fs'); +const yaml = require('js-yaml'); +const React = require('react'); +const Components = require(`${process.cwd()}/core/Components.js`); +const { + Block: { Container }, + BulletLine, +} = Components; + +const pluginsDirectory = require('path').join(process.cwd(), 'data/plugins'); +const pluginMetadata = fs + .readdirSync(pluginsDirectory) + .map(file => + yaml.safeLoad(fs.readFileSync(`./data/plugins/${file}`, 'utf8')), + ); +const truncate = text => + text.length > 170 ? text.substr(0, 170) + '...' : text; + +const addPluginDocsLink = '/docs/plugins/add-to-marketplace'; +const defaultIconUrl = 'img/logo-gradient-on-dark.svg'; + +const Plugins = () => ( +
+
+
+

Plugin marketplace

+

+ Open source plugins that you can add to your Backstage deployment. + Learn how to build a plugin. +

+ + + Add to marketplace + + +
+ + + {pluginMetadata.map( + ({ + iconUrl, + title, + description, + author, + authorUrl, + documentation, + category, + }) => ( +
+
+ {title} +

{title}

+

+ by {author} +

+ {category} +
+
+

{truncate(description)}

+
+ + + + Explore + + + +
+ ), + )} +
+
+

+ Do you have an existing plugin that you want to add to the + Marketplace? +

+

+ + Add to marketplace + +

+
+ +

+ See what plugins are already{' '} + + in progress + {' '} + and 👍. Missing a plugin for your favorite tool? Please{' '} + + suggest + {' '} + a new one. +

+
+
+
+
+
+); + +module.exports = Plugins; diff --git a/microsite/sidebars.json b/microsite/sidebars.json index c8b335660e..f7aa0630ef 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -4,10 +4,15 @@ "overview/what-is-backstage", "overview/architecture-overview", "overview/architecture-terminology", - "overview/roadmap" + "overview/roadmap", + "overview/vision", + "overview/background", + "overview/adopting", + "overview/logos" ], "Getting Started": [ "getting-started/index", + "getting-started/running-backstage-locally", "getting-started/installation", "getting-started/development-environment", "getting-started/create-an-app", @@ -28,15 +33,16 @@ ] } ], - "Features": [ + "Core Features": [ { "type": "subcategory", "label": "Software Catalog", "ids": [ "features/software-catalog/software-catalog-overview", + "features/software-catalog/installation", "features/software-catalog/system-model", "features/software-catalog/descriptor-format", - "features/software-catalog/populating-catalog", + "features/software-catalog/well-known-annotations", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", "features/software-catalog/software-catalog-api" @@ -44,9 +50,10 @@ }, { "type": "subcategory", - "label": "Software creation templates", + "label": "Software Templates", "ids": [ "features/software-templates/software-templates-index", + "features/software-templates/installation", "features/software-templates/adding-templates", "features/software-templates/extending/extending-index", "features/software-templates/extending/extending-templater", @@ -56,11 +63,12 @@ }, { "type": "subcategory", - "label": "Docs-like-code", + "label": "TechDocs", "ids": [ "features/techdocs/techdocs-overview", "features/techdocs/getting-started", "features/techdocs/concepts", + "features/techdocs/architecture", "features/techdocs/creating-and-publishing", "features/techdocs/faqs" ] @@ -72,6 +80,7 @@ "plugins/create-a-plugin", "plugins/plugin-development", "plugins/structure-of-a-plugin", + "plugins/integrating-plugin-into-service-catalog", { "type": "subcategory", "label": "Backends and APIs", @@ -89,7 +98,11 @@ { "type": "subcategory", "label": "Publishing", - "ids": ["plugins/publishing", "plugins/publish-private"] + "ids": [ + "plugins/publishing", + "plugins/publish-private", + "plugins/add-to-marketplace" + ] } ], "Configuration": [ @@ -103,7 +116,8 @@ "auth/add-auth-provider", "auth/auth-backend", "auth/oauth", - "auth/glossary" + "auth/glossary", + "auth/auth-backend-classes" ], "Designing for Backstage": [ @@ -129,7 +143,7 @@ "ids": ["api/backend"] } ], - "Tutorials": ["tutorials/index"], + "Tutorials": ["tutorials/journey"], "Architecture Decision Records (ADRs)": [ "architecture-decisions/adrs-overview", "architecture-decisions/adrs-adr001", diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js index f0c8ce5fa5..d7a3bd67d0 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -17,17 +17,12 @@ const siteConfig = { url: 'https://backstage.io', // Your website URL cname: 'backstage.io', baseUrl: '/', // Base URL for your project */ - // For github.io type URLs, you would set the url and baseUrl like: - // url: 'https://facebook.github.io', - // baseUrl: '/test-site/', + editUrl: 'https://github.com/spotify/backstage/edit/master/docs/', // Used for publishing and more projectName: 'backstage', organizationName: 'Spotify', fossWebsite: 'https://spotify.github.io/', - // For top-level user or org sites, the organization is still the same. - // e.g., for the https://JoelMarcey.github.io site, it would be set like... - // organizationName: 'JoelMarcey' // Google Analytics gaTrackingId: 'UA-48912878-10', @@ -35,13 +30,18 @@ const siteConfig = { // For no header links in the top nav bar -> headerLinks: [], headerLinks: [ { - href: 'https://github.com/spotify/backstage#backstage', + href: 'https://github.com/spotify/backstage', label: 'GitHub', }, { doc: 'overview/what-is-backstage', + href: '/docs', label: 'Docs', }, + { + page: 'plugins', + label: 'Plugins', + }, { page: 'blog', blog: true, @@ -51,10 +51,6 @@ const siteConfig = { page: 'demos', label: 'Demos', }, - { - page: 'background', - label: 'The Spotify story', - }, { href: 'https://mailchi.mp/spotify/backstage-community', label: 'Newsletter', @@ -71,8 +67,9 @@ const siteConfig = { primaryColor: '#36BAA2', secondaryColor: '#121212', textColor: '#FFFFFF', - navigatorTitleTextColor: '#9e9e9e', - navigatorItemTextColor: '#616161', + navigatorTitleTextColor: '#e4e4e4', + navigatorItemTextColor: '#9e9e9e', + navGroupSubcategoryTitleColor: '#9e9e9e', }, /* Colors for syntax highlighting */ @@ -97,8 +94,10 @@ const siteConfig = { cleanUrl: true, // Open Graph and Twitter card images. - ogImage: 'img/logo-gradient-on-dark.svg', - twitterImage: 'img/logo-gradient-on-dark.svg', + ogImage: + 'logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_04_Icon_Teal.png', + twitterImage: + 'logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_04_Icon_Teal.png', // For sites with a sizable amount of content, set collapsible to true. // Expand/collapse the links and subcategories under categories. @@ -118,6 +117,12 @@ const siteConfig = { stylesheets: [ 'https://fonts.googleapis.com/css?family=IBM+Plex+Mono:500,700&display=swap', ], + + algolia: { + apiKey: '8d115c9875ba0f4feaee95bab55a1645', + indexName: 'backstage', + searchParameters: {}, // Optional (if provided by Algolia) + }, }; module.exports = siteConfig; diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css index 392d9a31c2..111065a5d5 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -7,6 +7,14 @@ /* your custom css */ +/* override font color for new dark tech docs styling */ +table { + color: white; +} +table tr:nth-child(2n) { + background-color: #2b2b2b; +} + @media only screen and (min-device-width: 360px) and (max-device-width: 736px) { } @@ -50,7 +58,7 @@ ol, li, td { font-family: Helvetica Neue, sans-serif; - color: $textColor; + color: #e4e4e4; } .mainWrapper { @@ -105,6 +113,10 @@ td { color: $navigatorTitleTextColor; } +.toc .toggleNav .navGroup .navGroupSubcategoryTitle { + color: $navGroupSubcategoryTitleColor; +} + .toc .toggleNav ul li a, .onPageNav a { color: $navigatorItemTextColor; @@ -195,6 +207,54 @@ td { border-radius: 0.25rem; } +/* + * Fix for viewing Table of Contents bar on documentation + * and blog pages on smaller screens. + */ +@media only screen and (max-width: 1023px) { + /* Nav bar hides the docs toc bar */ + .docMainWrapper { + margin-top: 4rem; + } + + /* Toc bar does not have to be fixed */ + .docsNavContainer { + position: unset; + width: 95vw; + z-index: 100; + margin-left: -2vw; + } + + /* Toc bar does not have to be fixed when slider is active */ + .docsSliderActive .toc .navBreadcrumb, + .tocActive .navBreadcrumb { + position: unset; + } + + /* Fix unexpected width increase when toc is toggled */ + .docsSliderActive .toc .navBreadcrumb, + .tocActive .navBreadcrumb { + width: inherit; + } + + /* This pseudo-element stops toc toggle button to be clicked */ + header.postHeader::before { + height: 2em !important; + margin-top: -2em !important; + } + + /* This pseudo-element stops toc toggle button to be clicked */ + #__docusaurus.postHeaderTitle::before { + height: 1em; + margin-top: -1em; + } + + /* Useless button causing trouble */ + .tocToggler { + display: none; + } +} + /* content */ .postContainer blockquote { color: $textColor; @@ -208,7 +268,7 @@ td { code { font-family: IBM Plex Mono, Menlo, Monaco, Consolas, Courier New, monospace; font-weight: 500; - background-color: #0e0e0e; + background-color: #272822; } /* .stripe { @@ -280,17 +340,17 @@ code { background: linear-gradient(70.44deg, #121212 75%, #5d817b 100%); } .bg-teal-top-right { - background: + background: /* linear-gradient( 178.64deg, rgba(255, 255, 255, 0) 57.61%, rgba(255, 255, 255, 0.17) 127.71% - ), */ + ), */ /* linear-gradient( 144.35deg, rgba(98, 197, 179, 0) 56.68%, rgba(98, 197, 179, 0.59) 109.25% - ), */ + ), */ /* linear-gradient( 192.29deg, rgba(155, 240, 225, 0) 54.17%, diff --git a/microsite/static/css/plugins.css b/microsite/static/css/plugins.css new file mode 100644 index 0000000000..a753e16047 --- /dev/null +++ b/microsite/static/css/plugins.css @@ -0,0 +1,116 @@ +.PluginCard { + background-color: #272822; + height: 100%; + padding: 16px; + display: flex; + flex-direction: column; +} + +.grid { + display: grid; + grid-gap: 1rem; + grid-template-columns: repeat(4, 1fr); + grid-auto-rows: 1fr; + padding-top: 32px; +} + +@media (max-width: 1200px) { + .grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media only screen and (max-width: 815px) { + .grid { + grid-template-columns: repeat(2, 1fr); + } +} + +.PluginCard img { + float: left; + margin: 0px 16px 8px 0px; + height: 80px; + width: 80px; +} + +.PluginCardHeader { + max-height: fit-content; + min-height: fit-content; +} + +.PluginCardTitle { + color: white; + vertical-align: top; + margin: 8px 0px 0px 16px; +} + +.PluginAddNewButton { + position: absolute; + bottom: 16px; + right: 0px; +} + +.ButtonFilled { + padding: 4px 8px; + border-radius: 4px; + background-color: #36baa2; + color: white; + margin-top: 36px; +} + +.ButtonFilled:hover { + border: 1px solid #36baa2; + background-color: transparent; +} + +.ChipOutlined { + font-size: small; + border-radius: 16px; + padding: 2px 8px; + border: 1px solid #36baa2; + color: #36baa2; +} + +.PluginCardLink { + padding: 2px 8px; + position: absolute; + bottom: 0; + right: 0; +} + +.PluginPageLayout { + margin: auto; + max-width: 1430px; + padding: 20px; +} + +.PluginPageHeader { + position: relative; +} + +.PluginPageHeader h2 { + display: inline-block; +} + +.PluginCardBody { + padding-top: 8px; +} + +.PluginCardFooter { + position: relative; + min-height: 2em; +} + +.Author, +.Author a { + margin-bottom: 0.25em; + color: rgba(255, 255, 255, 0.6); +} + +.Author a:hover { + color: white; +} + +#add-plugin-card { + border: 1px solid #36baa2; +} diff --git a/microsite/static/img/techdocs2.gif b/microsite/static/img/techdocs2.gif new file mode 100644 index 0000000000..ba937581b3 Binary files /dev/null and b/microsite/static/img/techdocs2.gif differ diff --git a/microsite/static/logo_assets/Backstage_Identity_Assets_Overview.pdf b/microsite/static/logo_assets/Backstage_Identity_Assets_Overview.pdf new file mode 100644 index 0000000000..ad091c18e2 Binary files /dev/null and b/microsite/static/logo_assets/Backstage_Identity_Assets_Overview.pdf differ diff --git a/microsite/static/logo_assets/ai/Backstage_Identity_Assets_Artwork_RGB.ai b/microsite/static/logo_assets/ai/Backstage_Identity_Assets_Artwork_RGB.ai new file mode 100644 index 0000000000..f849131417 --- /dev/null +++ b/microsite/static/logo_assets/ai/Backstage_Identity_Assets_Artwork_RGB.ai @@ -0,0 +1,2999 @@ +%PDF-1.5 % +1 0 obj <>/OCGs[11 0 R 46 0 R 80 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream + + + + + application/pdf + + + Backstage Identity_Assets_Artwork_RGB + + + 2020-04-28T11:21:44+01:00 + 2020-04-28T11:21:44+01:00 + 2020-04-27T17:08:19+01:00 + Adobe Illustrator CC 23.1 (Macintosh) + + + + 256 + 108 + JPEG + /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgAbAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FXYq7FXYq7 FXYq7FXYq7FULqd99TtTNx5EkKvgCe5yUY2XF1mp8GHEk0N7xvoRDIJmklQS3ALLzD1HFkO23bLD HZ0+PU1ljwniuQuW4u+hHkmN5qNxDNMqAFYgrU9N2ry61cHiv05ARBdjqNXOEpAco1/CT9vIfFVb VoBM8XBiy1C04ksQwWlK1FSwpXBwNh10RIxo7e7vA7/Pq46lxuEieNkaQUWMgci1ada8aU3x4dlO rqYiQRfTrf3UtXVVkYLHExPNUYErUBq9RyqDt3w8CBrhI0AeYHT9baavC5KpGxckKigqSS1ffb7J rXHgWOvjLYA38PPz8urhrFsZETi4ZqAg8aqSxUVFfEdseAqNfAkCjv7tt67/ALkdkHOdirsVdirG /wAxvOcXkvyVqnmaS2a8GnohW2Vghd5ZUhQFj0XlICx8MVfM+ofmjKms22t6TqsOueYrx7W8n1qE 3dk1pE08UUmlNaSFoprernj06lvtipVe4/mL+YXmXQfMM2naUIzFBpP6TCfoq/1SSWX1ZI/TLWc0 S26UQfHIKYq4/nr5btrqG1v7aVZHszcvLbSW86l0sf0g6RxrL67IYgQkhjCs2wOKq+sfm02j6lpk Gr6Nd6WL5p7dbK4WF7ie5rbC1S2lima2YSfWSG5P8JG5WlSqp6j+c9sl1qdhYaPdTahplxHBJBJJ aIxU3sdm7mI3CyorNL+6Zlo+xrSuKqj/AJ2eX01a+0o2F215as0VtCj2jyXEy3kNh6QjWctEzT3K cPW41X4umKqOofnv5Z02Vbe/0++tr2N5lvrR/qvqQLbyCJ32nIlBY/CIuTMATToCq9KxV2KuxV2K uxV2KuxV2KuxV2KuxV2KqF7Zw3kBhlrxJqCDQgjvhjKmjUaeOWPDJCabocFnI0hb1mNOBKgcad++ +SlO3E0nZscJJviPu5IuWxtpXZ3ViXADgO4DAdAVBAOREi5c9NCRJN7+Z+61h0yyJJKHetBzag5H kaCu2++2HiLA6LH3faeu/wAN91w0+0404E16sWYtWvKta1rXvjxFl+Vx1y+0+9pdOtFNQrV2oebV HE1AG+wx4igaTGOn2nopyaTBwAhJjZacSWdgAvQD4gR9BGHja5aGNenY+8nl8dvgQvh023jSMblk pvVgDQlhUV3oT3wGRZ49HCIHl7/f+LRWRcp2KuxV2KpR5s8raP5r8vXvl/WY2l06+VVmVHMbAo6y IysvQq6Kw7bb1GKvMfy5/wCcZfLPk7zLNrM98dajCcLGzureMLCRIsiyO1XEkiMg4sFWnhXFXo2u +RPLeuX/ANf1CO6F2bf6m8lrfXtnzt+Rf0pFtZoVdeTn7QOKpW/5Pfl68lTpri3qxWyW6ultVL2p s3K26yCIc7c8Gou4xVEr+V/kn6vPDNYPdfWUliuZrq5uriaRZ0ijcPNNK8hotvGEPKqcfhpiqxvy r8lO8zyWtxIZldBzvr1vSEs6XMhtyZqws80KOWjINRiqX61+Tnli8tbkae09lfyh/q88tzeXMMJl uo7yXhAbhAnOeIOTGyMG+JWVgpCq7Rfyf8tWenJb6g019dmW4lubqO4vLf1hdSiaSKX/AEiSSaLk q/DNJJXuTU1VZ3irsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdir sVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirs VdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdiqld3UVrA00poq9vEno MIFtOfNHFEylySs6ndRXcSzVDzOq+gACgR9lZXG9fnlnCKdadZOOQCXORG3SjyIPenOVO4dirsVd irsVdirsVdirsVdiqXeYvMGleXdDvNb1ab0NOsIzLcS0LEAbABRuSxIAHjirwrzN+d/mzRLqDX70 PbQ6gLebQvL8S295Y3WmvIiTSzXkLtJFdAy9R+7B4qA9SQq+hsVdirsVdirsVdirsVdirsVdirsV dirsVdirsVdirsVdiqF1OyN7aNAG4EkEEioqMlGVFxdZpvGxmN0lel6BLFMXuqcUZWjCnqyk0J26 b5ZLJ3Os0XZcoyvJ0Nj3hPspd67FXYq7FXYq7FXYq7FXYq7FWO/mH5R/xf5M1Xy39Z+pnUYhGtzw 9TgyusgJSq1FU8cVfPv5c/8AOK2v2fme5Xze0Enl9I+KtaTnncOk0cqCnDkEPp/FyofDxxV9SYq7 FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7F XYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FX Yq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq0WUMFJAZug7mmKDIXTeKXYq7 FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqhdQ1GCy h5yGrn7EY6sclGNuLqtXDDGzz6BK4NKutQBvbuVopXANuF/ZHUHLDMDYOsx6Keo/eZDwyP010VU1 G90+VINRAeJjSO5X/jbBwg8m2OryYJCGbeJ5S/H496c5U7h2KuxV2KuxV2KuxV2KuxV2KuxV2Kux V2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KoXUr9LG2MzKWPRQOlT4nsMlGNlxdXqRhhxVaWa bYpqJa+vXWYv8KxKdkHvTpk5S4dg63SaYai8uU8V9O5u4gvdHQzWsnq2gPxQyb8amm304giXPmnL jy6QcUDxY+49EDe3c99LELgoqKAywpU7MAas3QbdfDJxFcnB1OeWaQ46rnQ/Sen6GVZjvUuxV2Ku xV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVIPN03pw21Ptl2C 8W4v0/Yr8Lf6vftlmN1Pav0x77+Pw7/d1Y3BqLwOZ4JfRddnlRTwG/SWLcp9xHhlp3dLCZgeKJ4T 3jl/nR6fd3ImfXbm/wCMMtykiVr6Vurcmp3+IAD6T9GARAbM2qnmqMpAjuiDv+PwEVPccZ4xRvSK RcaCkZPBacD1mPh/TEM8u0h3VH3chy/n+X6mYZjvUuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2K uxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVJvNOi3Wq2SR20kayRMW9KZeUclRTiSPiX/WG+SjKnC1 2mlmhQI27+Red3T3ljdCC7jkt7tB8CSSCKWg2rDc/YkX2bfsMuBecyQlCVSBEvfR+Euo9/uDZvZw tLgzpEev1pxFH/wK/FL8lxYkn+Lir+lsP1y+D0XSdGseMGoSws168UZZ5ftAhAB8FeKnbt0ykyPJ 6TT6THtkI9dDn7u7om+Rc52KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Kux V2KuxV2KuxV2KuxVLfMH6A+oH9Oej9Urt61Ptf5H7Vf9XfDG+jjarwuD97XD5sa0H/lVv6QT9G+j 9cqPS9b1vtV24ev8PLwpvkzxOu035Hj9FcXnf++ZvlbunYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXY q7FX/9k= + + + + uuid:a094fc3f-430f-7744-b388-fdb7ec44f815 + xmp.did:17c4aec8-eddf-4473-bdc7-9dbc4289c51b + uuid:5D20892493BFDB11914A8590D31508C8 + proof:pdf + + xmp.iid:c8804a9b-d36f-42ac-b594-d814edaf8ca1 + xmp.did:c8804a9b-d36f-42ac-b594-d814edaf8ca1 + uuid:5D20892493BFDB11914A8590D31508C8 + proof:pdf + + + + + saved + xmp.iid:96cc7365-26ab-46ae-a826-605707e77cc1 + 2020-04-20T16:59:06+01:00 + Adobe Illustrator CC 23.1 (Macintosh) + / + + + saved + xmp.iid:17c4aec8-eddf-4473-bdc7-9dbc4289c51b + 2020-04-27T17:08:17+01:00 + Adobe Illustrator CC 23.1 (Macintosh) + / + + + + Print + Document + Adobe PDF library 15.00 + 1 + False + False + + 346.562312 + 440.050339 + Millimeters + + + + Cyan + Magenta + Yellow + Black + + + + + + Default Swatch Group + 0 + + + + White + RGB + PROCESS + 255 + 255 + 255 + + + + + + Backstage RGB + 1 + + + + Teal 01 + PROCESS + 100.000000 + RGB + 53 + 185 + 162 + + + Teal 02 + PROCESS + 100.000000 + RGB + 125 + 242 + 224 + + + Magenta + PROCESS + 100.000000 + RGB + 240 + 55 + 165 + + + Blue + PROCESS + 100.000000 + RGB + 46 + 119 + 208 + + + Grey + PROCESS + 100.000000 + RGB + 64 + 64 + 64 + + + Black + PROCESS + 100.000000 + RGB + 18 + 18 + 18 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + endstream endobj 3 0 obj <> endobj 5 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 2079.95 456.054]/Type/Page>> endobj 6 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 337.465 428.499]/Type/Page>> endobj 7 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 2079.95 456.054]/Type/Page>> endobj 8 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 337.465 428.499]/Type/Page>> endobj 9 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 2079.95 456.054]/Type/Page>> endobj 10 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 337.465 428.499]/Type/Page>> endobj 13 0 obj <>/Resources<>/ExtGState<>/Properties<>/Shading<>>>/TrimBox[0.0 0.0 982.381 1247.39]/Type/Page>> endobj 91 0 obj <>stream +Hd6y +dFpO.`%Ͱf"}ϗնJs+T-mWؼݼ=Gc)נ_=Y +Vx{ߊǴVԮ뉼_f\Oēl+m^ɧ{x"Wy/7kqs>sƺ[,[ mDyhbWbi?ne.Ǧm|{ Vae#{6ֹv@a:cC>_'B r܎ +i2lb{ʄxWʎ޶VR+#Myfڬ|yoi{pv +)6≼A]gN:57gS+g aI>"dðu3^aG5rDam9QVP;cWv_34~-dak=u2FjQBg,0ȹ"1QoA{KH̸!֍qT(6FBDB n@(k@-N: ={L1DHZH{X|!y#J@6}q~du{ n7:xE1| ְ=4j +⅔@*5Q fbJQUP\k?dqk![#5L%Aa$l~6 TDCkq, +#d@I]E2$-|"+@.c=EHZ%IFU6LazE%AC4N@0I癌ewSȸȌ6'ŨULqwF7I;p~xb4 bHZu5R5v'B عȊrἫ Fyv5EqqDJNDU "j&&0ZyO) 9 ᜨ;)Ea^]d{Yhx5!%rx47D:V'ht9ZKj,.M~k5 X CfIAS=z4m=W&U))S!oSI5ym<6o,ܑߨ:]dj#T]ijtj7{6 )fѢ +Gk$ue(-뎼/51~ $ u4Z4v#*ÚYaC +fEPHq" $7!BJ?pnUtnEwxwVB*7ZXj7L<+at~%#?W]ʹeW7Mq3ej?fNے+mD3=az1-ODqD"?](;K&*Ʌ\T-DLxz>Ei4`7M%I] +CJYe7I.䒤3RzKr%LM&Ʌ\tF&]d=%);Vn\%Io8KS֛$!K}9ˁ-%o[ȥC}qG_Էޝ}-% 1Է鞆–ȮzUբ>SCk2*n gL^=xEz*\TYٌvWEoe :GUU\EK;XE80WW**\bѶYwjEغZg׷JT .Ф:aZ>MCH%]߷o~}/߷ׯ 0c endstream endobj 92 0 obj <> endobj 84 0 obj [/ICCBased 94 0 R] endobj 93 0 obj <> endobj 95 0 obj <> endobj 96 0 obj <> endobj 97 0 obj <> endobj 94 0 obj <>stream +HyTSwoɞc [5laQIBHADED2mtFOE.c}08׎8GNg9w߽'0 ֠Jb  + 2y.-;!KZ ^i"L0- @8(r;q7Ly&Qq4j|9 +V)gB0iW8#8wթ8_٥ʨQQj@&A)/g>'Kt;\ ӥ$պFZUn(4T%)뫔0C&Zi8bxEB;Pӓ̹A om?W= +x-[0}y)7ta>jT7@tܛ`q2ʀ&6ZLĄ?_yxg)˔zçLU*uSkSeO4?׸c. R ߁-25 S>ӣVd`rn~Y&+`;A4 A9=-tl`;~p Gp| [`L`< "A YA+Cb(R,*T2B- +ꇆnQt}MA0alSx k&^>0|>_',G!"F$H:R!zFQd?r 9\A&G rQ hE]a4zBgE#H *B=0HIpp0MxJ$D1D, VĭKĻYdE"EI2EBGt4MzNr!YK ?%_&#(0J:EAiQ(()ӔWT6U@P+!~mD eԴ!hӦh/']B/ҏӿ?a0nhF!X8܌kc&5S6lIa2cKMA!E#ƒdV(kel }}Cq9 +N')].uJr + wG xR^[oƜchg`>b$*~ :Eb~,m,-ݖ,Y¬*6X[ݱF=3뭷Y~dó ti zf6~`{v.Ng#{}}jc1X6fm;'_9 r:8q:˜O:ϸ8uJqnv=MmR 4 +n3ܣkGݯz=[==<=GTB(/S,]6*-W:#7*e^YDY}UjAyT`#D="b{ų+ʯ:!kJ4Gmt}uC%K7YVfFY .=b?SƕƩȺy چ k5%4m7lqlioZlG+Zz͹mzy]?uuw|"űNwW&e֥ﺱ*|j5kyݭǯg^ykEklD_p߶7Dmo꿻1ml{Mś nLl<9O[$h՛BdҞ@iءG&vVǥ8nRĩ7u\ЭD-u`ֲK³8%yhYѹJº;.! +zpg_XQKFAǿ=ȼ:ɹ8ʷ6˶5̵5͵6ζ7ϸ9к<Ѿ?DINU\dlvۀ܊ݖޢ)߯6DScs 2F[p(@Xr4Pm8Ww)Km endstream endobj 80 0 obj <> endobj 98 0 obj [/View/Design] endobj 99 0 obj <>>> endobj 85 0 obj <> endobj 83 0 obj <> endobj 100 0 obj <> endobj 101 0 obj <>stream +%!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 17.0 %%AI8_CreatorVersion: 23.1.1 %%For: (Natalie Strange) () %%Title: (Backstage Identity_Assets_Artwork_RGB.ai) %%CreationDate: 28/04/2020 11:21 %%Canvassize: 16383 %%BoundingBox: -5080 1539 4131 5351 %%HiResBoundingBox: -5079.08096277355 1539.48247256369 4130.81283900864 5350.91952314631 %%DocumentProcessColors: Cyan Magenta Yellow Black %AI5_FileFormat 13.0 %AI12_BuildNumber: 673 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%RGBProcessColor: 0.070618383586407 0.070618391036987 0.070618383586407 (Black) %%+ 0.180392161011696 0.466666668653488 0.815686285495758 (Blue) %%+ 0.250980406999588 0.250980406999588 0.250980406999588 (Grey) %%+ 0.941176474094391 0.215686276555061 0.647058844566345 (Magenta) %%+ 0.211763471364975 0.729411721229553 0.635294198989868 (Teal 01) %%+ 0.49064376950264 0.95199066400528 0.881853818893433 (Teal 02) %%+ 0 0 0 ([Registration]) %AI3_Cropmarks: -880.950314739645 1539.48247256369 101.431041134967 2786.86926132895 %AI3_TemplateBox: 306.5 -396.5 306.5 -396.5 %AI3_TileBox: -669.259636802339 1783.17586694632 -110.259636802339 2566.17586694632 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 1 %AI9_ColorModel: 1 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 1 %AI9_OpenToView: -3450 7755 0.09852450408425 1242 674 18 0 0 11 51 0 0 0 1 1 0 1 1 0 0 %AI5_OpenViewLayers: 7 %%PageOrigin:0 -792 %AI7_GridSettings: 72 8 72 8 1 0 0.800000011920929 0.800000011920929 0.800000011920929 0.899999976158142 0.899999976158142 0.899999976158142 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 102 0 obj <>stream +%%BoundingBox: -5080 1539 4131 5351 %%HiResBoundingBox: -5079.08096277355 1539.48247256369 4130.81283900864 5350.91952314631 %AI7_Thumbnail: 128 56 8 %%BeginData: 3000 Hex Bytes %0000330000660000990000CC0033000033330033660033990033CC0033FF %0066000066330066660066990066CC0066FF009900009933009966009999 %0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66 %00FF9900FFCC3300003300333300663300993300CC3300FF333300333333 %3333663333993333CC3333FF3366003366333366663366993366CC3366FF %3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99 %33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033 %6600666600996600CC6600FF6633006633336633666633996633CC6633FF %6666006666336666666666996666CC6666FF669900669933669966669999 %6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33 %66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF %9933009933339933669933999933CC9933FF996600996633996666996699 %9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33 %99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF %CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399 %CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933 %CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF %CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC %FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699 %FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33 %FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100 %000011111111220000002200000022222222440000004400000044444444 %550000005500000055555555770000007700000077777777880000008800 %000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB %DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF %00FF0000FFFFFF0000FF00FFFFFF00FFFFFF %524C45FD35FF8BAFFD2EFF5252A8FD4CFF60AF8AAFFFAFAEAFFD06FFAFFD %21FF277D7D52FFA87D7DA8FD05FFA8FD40FF8AFF8A8BAFFF8AAF8BFFAEFF %FD09AFFFAFFFFD04AFFD14FF277D7D2727FF7D7D52A87DA8A87D7D7DA8A8 %7DA852A87DFF7D7DA8A87DFD32FF848B848B60AFFFAF84AF8A8B84AFAFAF %60AF8A8B84AF8A8B84AF8A8B84AFFD12FFA8272727F827FF7D527D527D27 %527D7D27527D52527D7D52277D7D522752FD33FF668B66AFAFFF8AFFAFAF %8AFD07AF8AAFAFAF8AAFAFAF8BAFAFFD13FFF8F8F8277DFF7D7D7DFD0452 %FD057D527D7D7D52527D7D527D52FD32FFAFAF608BAFFFFFFFAEFFAFAFAF %FFAFFFFFFFAFFFAFFFAFFFAE8B8AFFAEFD14FFA827F852FFFFFF7DFD04A8 %FF7DFFFFFFA8A8A8FFA8A8A85227A8A8A8FD4BFFAFFD19FFA8FD15FFA8FD %FCFFFDFCFFFDFCFFFDFCFFFDFCFFFD59FFAFAFFD2EFFA87DFD4DFFAF8AAF %AFFD2CFF7D277D52FD4BFFAF8AAF60AFFD2BFFA8527D5227A8FD4AFF66FF %8A8B8BFD2BFF277D7D2727FD4AFFAE8B608B60AFFD2BFF27F8F82752FD4B %FF8A8B66AFFD2CFF52F8F852A8FD4CFF84AFFD2EFF7D52A8FDFCFFFDFCFF %FDFCFFFDFCFFFDFCFFFDFCFFFDE6FFAF60AFFD7DFF668B608BAFFD79FFAF %608BFFAF60678AFD77FFAF66AFFD04FFAF8B8AFD75FFAF606684FD05FF8A %66AFFD73FFAF60AFAF8B60AFFFFFAF8B8AFD73FFAF608BAFFFAF8B608B84 %8A60AFFD73FF668BFD06FF668B8AAF8BFD72FF848B608BAFFD04FF8B8A8B %84AFFD72FFAF848B668BAEFFFF8B60AF8B8B8AFD72FF848A608A8466608A %608A848B848BAFFD71FFAF608B848B848B8A8B84AF8A8B8AFD72FF846060 %8A6084608A608A848B608AFD72FFAF6084608A608A608A60AF608BFD73FF %84605F605F8460605F6084FD76FFA8605F8A6060608A60AFFD77FFAF8435 %605F605F60A8FD79FFAF5F605F60A8FD7CFFA8FD3EFFFF %%EndData endstream endobj 103 0 obj <>stream + mgcR^vЬe1@QXH|rueEZp/:A] tP7GRM䜭Je^N9,l1,R58I>u "rg&$20YEo GtCo: 4d1bO9D+zDL!2*4\DbZ$$,CRH(`Bx9j솲.q8 `jśQo`.Mpl"#5񴆞2iՆvB3k':%4nj.ښZ\~(IT !㕗;:ySu!kfxׂ03;MNq܆J5ptKNAi]2qW7Zz# ր;0ǽ943bLIE՘Uxb/љN=N%.!_A0UN3m@l9pfB"FQ;Š DtS[ڗ8~*` y? +P3! +`0mT`$2P֩,"~uf3\aNߨW TA@i@&KxOg!ѵqFy@-k\|vBcj9I #T5u۱:"1x ťb9@OQ^ʺv]cfOGȠתl)QMC5p*@`eFQ=ճ h{ dr+` BB3,C",D jFfrs ?=X90j`;lκTŌ:0y5ilQ͓7 ߧB +IR(GCh۪;MkAoGF3f; ޗhʶE. +3lwBgPyXiANs]i>=X<㦟."{\HD- YWRI9?9B DR#6M3LbC*shӃy<<\|N~ƞcFB6 +A|@la +o ZfP4*{=&P+BM#hA]Qa]Eߑ zd }%Sx-0YLv?rCCM˰5gF/֧UK0 x49 FŠE"uɸih"xt@9/. '0͎Aϐo@$<4dĘi"r]e,`MP2pn\ǘ<@-a1 y,2jzbR28\Ӛe"LHChCACz\ct,b:8j y5c_0YhlNf*A/3?h~ťqi\#N˅[sҏqً9Gz^itܗ3wLA# &3C&0wOf.5N>&XTLRf9dfwgC"̨85v 4wK@̺XF47"L.3)ЋӃ`uffYy@N@_S@1SAYੲ +.4Ug\ǂ4fJL8rtW3rt4?\>=X +s}*R@E&%VS/穙MQxФ +5E99dQs^s*!*_ںէ/wqFFP1%V')!YUN\]~!`@:E%7~yq8*SjrG40"(i:ƛ<'3=XpK +\\( }[}ǴMLnjt^ h` ȝ>igVf8uwI{>#FW=r0xTz~J}T!>6$kp#MJ&LECMxor*72q?fHr +?6n. 8XaH; +}z%4"LtA&j#pq[9/4! @m[Q>5o?Z}V,46rV` 3E:x"š \F}QHYFCY35e 9کk"qk:BPh?( E+/Bg8Χ{f+! X}R-@ErfT ݔ[u/7R8Y!!Zf]΀`ĠIQ0a] 4C(C~K!HΒYV|*<ٚNUyAwH P9JI$*+$j`yx7qM0A`g岣9HGH2a%m-C=1>$ԫZX*c^. +@?/CvС +s)@+\3? pZKT@ ~s+k9L,j$H +c#}s0Qjv0i%&+zX5_=td1q£lA3qXr2O`a2,KO1V/?Y8e4@;j +fl 9!_XJFX4L|IӼӲ4N#):\NA26?V>qǬ`wq9.U &.ue3^L[򫳄1oi?xVTdLTyo09ӛID[۲hC5-V|q7'WߟR}sLۯ R!ڑ$Df>0jUF 2 kdf~6Z`g|'߯ +ggW? #ݥO煬8!'OOT?0àD{]Bzޕ! }rfb!"0n9'yH"6 Lejo?YF$WBm+Ex,=ί{{( f$aN<bkov4Ƚ;҃"9z7mpޚؽg$gpW?3w +7Nf-u!Ki<^r]b)EaHK"Km,6(HŐ6_HzHq~բ񅘇H[@!"ֿ6[) w*piyFj\7Suä.N0\W?bc ]xi3hNƛʗuo\|QcU +Ej¿,)t?'65oⓉU褛A͝>3dOz>&Zŏ'F7O)^chEBt 2}bPGVC_ǐ!M"C1Iܺy(:3{%Ǔ|з/om2nm^zu}kv={joVڛv61 R=k]F[Uh_^Z@r}in;}OH=otyGWoyz|Pljȵ CA֘P'=h#OtCл\kl1Aj\ +g88{tk,>Lb1i.x&sFqK䞫PD +U5^HbFdqvX'g;d#IrvAD#;u,,,dBl "&a> 9OӐ#F͋.TT'^oaѮ3%+|OtB#2SYUο!!|!"IwU]a.UޞI%edzniDMd ވ0;UU!iV{/t>p+մ[{p3зZq[M^n2/zytLxSV騥مٻhfuoԢZ@nh+2 M>n32L@ZɝC /wo40u"iF|sNMsGDXIR߲"S=KkQzp<*""Q PŬ43 ECP1В ZˍG֔\&c{-D6I,mâ͈a45[mimx:Ċj',ְlrqni(m5kycC i$AY{+}Y&i+="&#Wd WglG9~'VO|ٓgB^7O>鋿]?9{~}}8xr̲b^аq5QԦVeׂz0Pf${]-!([}4c?gA?FqzhtX,AŞQţ2GBg\`ɜ% <5/۰KOB45LGl +++ShD%`рLXNe@XA)1v;|!P]"w{e`HP:iDKgٺdHXQ  mV\e\]nQZ/Cy`^ 3\2D0Hpڄ!4Q[ *-.OP^Z +H%'d)jV %z=DbTƵ,w]HRl6sv` EDF (oEHBVkGvkV=&yZpHڋAA<܃ F,fjC1Ⱦ]bK<{0R+8*ҰWXmhXhqV:e7TPl#)% +s Lo:-1 bnɧ2hx5zȵp[TOZX85Ъ}tײ$fk\B-UxPûR'U|fW .פbHbR=v5:E}+;mb=׋M` +uxvk@ MlR;Gpne PO c4khqkui=:6&EFٸid3lʒO,B pJ|=d8;UZAkAXX+ lya(:gġFO #eҁ= 'Np,8ĪٍEhs$X\zKĶYEo7M ŭYZ6Zm TI°ȜPgdWqAV# ]6Cx{ڄPJ{AjS4a]%zώѮБ'v%2zg,nLй'`gj!F0fq;9[Ď|sTXOku@EOdg +!!2E]sԐ;C#z^+zU4!kn8aV`殲g'&=S:1b(*9Vv|OAU2\5,1zb`ijh3j#8 m'olrM(ŒsKֱz'lp@b/kv~1{^8~Ud DY!BsrIyA)Œ*Z뼋*+!+J2g + #ƒLu,xů.+1 ZO"ac:kl +Bfgc" gUT;݇F{uNŷ"2q>5晁Sk D2*NE0C,zl`o";X JCD:/;:,ǜe>vw7I!Zi5L ֬5!x,ǥ{:AёePWt0mxW><DJ.p,!8YA\f,i>5-@kު,|]v_Rdސn2k\50jVqko)=; +.X(٣S ikat;(J6rpL{Z7Ѯ/!*1xOxzaYNJH8ĪDηנg2v8{ &"*i;> )(c +*eh::EVP"eQX-L:SGI!L]HikjYLt:Y*ڒøKil,jF# Z,i2Ue/6ߔҖTp\ʉ[߫ϓfzT'z +yk1 L]^O;]6)֣ Z .6;"#HKRuq0)2AR{b#*fAyrI1FM䱛im5T`6IrŨy +,l^kг2%2T;0˻Z&J61>G&` k Pk!VNe)ԳmW9_iW؁{.7B]"A;)uUD*/ySv;-mڧ5vY`& -TOqm2 T5}IY A# gʍֶHχfP]Ɔ3I|SLPxhQiVI@ VTKQCZ5ͫEEUtŮi̜m~Pe璏jW"Zjφf%r]ݔz; ^N4q i΋&{)KE}v5N@ͣD+}ߍM-,AxhJ`U!h(%I5{J,a@?Y۔4Ъ#`ݦVSKje옜7:r\kr|5$X>R6IU07k|"gcZlPl(ZgV[>=ʎm>;;e~ZC9\}~rëӳoh9ah1٪s|0u|J (*d1%t5HZV xZh'-˳NșڨPKY~M)zJ!K~ ~UmQkD Tdf> 1j(W +=-W.~'j?narGΦ-i9@k^E *^PA!bPkS!('m Og_ j ӈ,5v+"V"lA>,Rǡm(ehM 6A:@kz8^,Na`*ehj1>= r\_tRM-K8'2*K&A~&!2=j*S>&jBl~n a դ0u@w~=1lȽQNv e!ril .s &M~+ۑ='=(TTNwHPɇ)c6MBM2zu:vL(տ '"nH;!AZNN8J=2B7ֽ:/Wv܉2M~+ ?4(R(tW R 丅@oce_떷6qZ,P6`  /w-T?d'IT&Kw̐D:[:=c wmtPK>GcCn+JqvqJ!΄8y ~-tNke@M|9RLbo&i&L:t@kiv H}{@i(7'Q.b/]1_sK{=Ecl/klp].ZmW ?td 5I-Ki EBhStMk4?*T2[)gG" 3H +,0y6ID]WBT-,QDI +)-bj +0|@. l $eNX}{PA;xtu%PuȚITzOm502r #(*員=ĒHp(u +kS&3kPئ,WvR/ۭɱ]N<;yb 2 s7K2` ͤY @GIc.r 6}λٻ.ǚljAZ[ w,&?0bwQ/H7ludg nDTP@ooΌd h59Qf8 +ף;7"j2EuRMSv\IVGςtdHkm53I(biW2eQlr|Ƹ?}J8@DTα~sMY97r#A+@c^x!3JBlQs̨&[F +F\nN͓M_}|?"b&RDTe5"/?^@HrN +`DwB:F1ПpIf8ۮq&1<:Qzjq-Lʇ j + ۦy}CG&2v$l>+e$O?JXlrl FllEv^͠m\;~RAɩ?8L⑶fMmU\uG`G彌>h!DiW #fv1] ,i t4+fQ|(5Rv2V2Z Thf7Ժ̞AAsd&ee/yζ@[sFWPȰ87DuÌjSF`WtӃV?X\>(27yvK|VurӨ!MW&*\N5ړX.l qaF"OZ{Mk&yal،Qd@^Bt2/zB2LDMF'}:bJ7E05|peR*5"TtWYT8T>xj̥s#+Ѽ ٷtilIW,}TLN@3{b&USbj|=):0@TvȨ)y+.+A䲲˪j̖2XH^w|LjG)\V ]dh3d ܃@ +ʨCa Fzr ʱ^r(딙!f6fWCjdi zN^Uom_eYRĄԾ6f\V0'*%hV5¨1O?Q1 M+x\nFLnT4m`rփcuUۊ w;^1pRr]m!۪|ni(D*2gsr~pHmqrPx?#qk551M;*}7v+XJr[oSŨ2V+`T,~{vJ\0 %a]=JMٿ %ى8dϩ;W$S1f[!zi:c`0xǦ>{%(+Yd적>!ge@][>%`fWmE1[]Tp'z\\*U+q%,&N4zH;֕"?¥pՖBTT6L&>] .%BKuk]uf!]X#"#\(SnB wݲb%]E4!GX^=ub9i,  HgB@)<8C t`hKMf5j>@1lչּMKMXxl{p  ,á|Bc8ʏFc%E iq]9ꥀ&!=uh@Rc-=mr4vc٫Q't"IP =,A/g0Yoh+ў3a3Y]38* epx|Z[06*N=;5zq A :y٤GdW/fXSH;ˮ0hQmxk^^k̉/YZxjgٹ0Aytir1VfPPw<ޖA+@\iP*0[u}VJA ɭ&o„' ҾPw `*RGn!dxJԠ j1dCE9te:{#LbU1dh]nO͡%LCw)s5*P.3bvx,sL|uU‡`5G*yvr3dQ <#2`T/kZݟ꣌ufnJPOTّ\`|zrH~0T64js([#˖`(OT. ' GlFI1ffR>|ވZߺn'#ڻX O8>-d*#/-KʺNJ](e@^aWV3k +횼 +dwO5I ,<,opaF'|BjtV_D%$ +^9eV7N47ȧNtQڍQ-Yqz8CyqjQZ$0&15g'=>-#{|ZDiMdO=>>M#{|Z"ǧEdOk"{|G4729bǧEdOk"{|ZD4졦[e#0ǧEdODLkms= -I=>-"{|Z"ǧEdOk"{|ZDiMd9iӚ=>-"{|\."{|\D&[q2EdȞ *4BG+=>#{|\"Ed&kd]$`쎏k"{|\DqMd=><~`==>."{|\<ǧEdOk"{|ZDiMdO=>i"ǧ7GPq Gwyj2P0Pk琅bM>183)& 2pb2 Vj2MEjP--M>U/ &{MddA .0Nm2`2 |Z lb2pf2 h x+&& +qSdAMY @e@ia2g&Non22&.vj2lJ -&QdAuddPl6dPe2 @&ك*́>l"IZ fˠ`Z5Ld-Lɀ'15ddb2lYd0C(5L'+3`g2dP}6dPXh +-Lލ/ &Pmn2NՏxi2Zx;.LVLmG6Fw(MSaP$4Ӆ& uX%^T5jj [N^=uGAf, nQo# v!Pؚ@ФYW@y$'i_f(40}GxAr|`Z- kSAøE#Xj P̅b> +*~d 8+fiw Y Ul=(PI@T;/:F]( m$Qc8x +9=Tփ*"AV+QmѡޭcxP@2Tl@zLHbB*q2sA.]% ZKl=H֊ +Av׌ͫV/ƃt&JDWã Y v׶= M֠A[3\U6_9lpКmlN0ȆȺb8@C6f#3AȠFh;|*0sJ൅dk=.`\(;9b8ҥ ZJ&xj7Z\7%jA[ydQg]ni;tY ;R8wu;<, `WΘT?XYGߪ]pE8Z.o~boHXc:a!S +?WWBk4Vuj"R8V3TkFQWk]YrLke_$Im\ 7N6W:Î*=+5o4}% +9RGJp~\2+XâvJ-榥2CJ? q Rvٽ.?M]].6C*vtۃΎq|tubvEg>WWˁjv}]j3nV,%H FYSpIk|(r&5%0&PBc'lGP`&.tPйiُRJI,QcLe}(~X?;6Rtw>K¾l5Rzã!q\EXW +cp-JHM]=o*%ƱJ] 6UƻBB^6m \Tq-CV& I݇l + {X)i-+В|||3Pqc&$ :RQ h HUL! oUW̷ϩt@i.o !]@3P'Vw]iXj2Y2Te+gzQ3??t7/cqdP־ U۪nA|&/M0=”}By +^~46dL4ɘ"K[HV,ёF@Ey@Whh|#:K܈GQfdtR]+֮QD 7 BQ/{eרdL3(5$a?eQWCbϺ;8~Z;ܚ~u@ A%=/u+q%+}K _Qs\VpQňj9TS`:7~=7}Þ93'2USN ٥է4o(?ec?^jHJy-w YwwgW7崫0yC):ev1}TVo!fZ|VzWȫj{AWQFOe Ϥp:}̅uVWgOLnr&xV~w.wK r)vyQu.M9)3djM`h"cld+X́5] zg7klu`ucC`XLy붣%kąd#~r}}?g_-_ޞ\Ջ//A7tpH!5cAVDCNS˳Ono_]}uzrq~nΟ<W;?ggS7L? ?Z}ȃͷzOdWkٛ뛕L#AI^OâVw8/!iZ6grr%I-ZMKdez,hz:,D`C!O +$hkOD>h5ᶽvNp;  lD!ڍ e֒CtmQ_f1)߳:[pP!QRo @?ZhA9S+HgB2IQ M<ưԧ]RR霦"!TE%6KZ'jEԯcOh7c2N`!'gVrƙ0YS-)KՖMaUe{M"yYg7\T>azaP.m#W=KCJ:~hozOZ'[qH +BX}q{hȱ,˘]c*#u{xAcV^26fFpL),/-Gỷrqw&ܧCӣoNsi_f \UF-Vh9F uzz5K̬͍J rv9kf8ɚUa̧ :ʴ`&YvВghLc ҰJ~FFʪqSO"F-rn_[kG7< %`{i@g,j^193oi-N81 d"cԩ%-gRdjUwByiiBEwvGky4*c4$5[5 z"PEkDMZdc"A +.C U#!NHw%*dPDOkBMCxy54eJXlZM.n#iAJ(bK9AW ڠ)fimо`=⥩!SN~e%4ũ~ͺ?B4 byB()ʽˌ6dC܃XA4x(َHKu;2E +ϱ| +'_P`AbX=B!kؔ1\? &$BP-F[>N0|6ORiG"&mɎ!,(=`Rf^<//k@sV ~4W.j.y_'֎ mRK2z(D`@4 J w)MtN#W bXVݭV#Oۄ;{xF@r|_@@B05z:JTJH( ނ y;hX!VO-GK :6:#*$5СJ-i:0 :eJ/rRꕎF-R>i Fi%.e'P(YS.jV,#C<)c)\]p 8c4x#; COl#D~A%Sլ'6Lh%*R"|k+[+? Z1AKŀ7ٛ3NxK6Z˳ G;W(+) |'XJ.NRR`uت2@(DAK1"Z18y< + :ǒNU#B-3ho(V^L$;LN`L$ +(.w=#UARH'#(Px+> @M\+|  gu~CgbX@Dc7Bd 2)%Q2| +2 +-H*ع;P8WǺ"lSv"p4r"~f򍈇XOXDR68Hm!3@J׎o`R($0$ 7.F0E?~p%p۶L(h$ C#DMeWB7K&q̩G^G +pzF +By ^%˃N'!l}J1n(m0%YVlt`ԯf fizGȐz$lıqb/sa}k% 3ެ^U +xAإU%\ۅ3R3"):׉O ,bLU#m2I7P ė$"%6]hq $gVw$vHвQ<R ơ& z)/X+&73XmJdiYTop %Ա $dj4dr>`Nwa~Wa`ߊS,u Ui87rl(I8[3 +$') %Xb EjR8gGnSXG)N`1ߖiّX 'exg^5A,J QˌPćˣC(%bTRً<`Xc\Iq0Nt@A= 40AAל'0B!'@u4QDۍ~%O$˺`|R^$~zl,n22 Zz0*;bpgM꙳X mVU KIJ)N>V@"qxB3-ɆA2%޸蠁Z dRyOV/I.RQҏރCꨬF]K$hp6 CjxgI:+ʺXgIFa?Q% 0ɝ$$) (qq@CΣ#N 52 }ְ$0*~@V1;2$wDn '8nb*r$M NأL&Wi53Rajpp`ɇ$ ہ]U@Ӌ,Ix  +Ot~5 "h-&1e-mo1XH^T&=+$" +o4)TDx!0$=kB@Fkĸ6 +X$YBY "Ȕqup&p /Al/r2,!_+@Ht?.  1d ~`, .ΐYkY49!V&N(6Q"F}/NG*%  R,KnԆOdoHzV" |{D2"GǥJC&Ec yf3L8`H1 +A[HւoN.)Hqx.>%|"CUkM݁ى#.'Ithִx +ѠHbpt2dEH,h,,d 5@"xH,_*J8`(3ãIΗ#+dH=*H1b"\!DU& tLB;p'w<Y xRp;$ 8woHh^ "Qdx ܁W.P`*hdSE:rD!!]a zEBΎjVT刌#@̈́D +:U%6#@j Č}DCqfۈFЁ[#%@@hHUqHޱipIy|26TX)Rt'Y܆KL| +NhPUX¹"Riu)rd͝"Y@C5>CY(pIa ֑(JArƚhW\/^=.(!q"B(ølB= ,Aз @8yaH +uO0>X>-ӢD~V4p=$.cd",v[ ː@3d }ƂjweA784~zdN_^.#l*f5>A1(>D*k\8FԆ a~OnѴ! ᰬB$ l>,eu; +z`HJ`] * -X3F,KRx +#AB_idOy$,zD'8 +@ +npI \47r@p6y||9m8KsB +(7pKqiP`Ik1& QYBeGZ +"ACj_τyDKȢRIi8S 0R$X,XA)pYOg8!uL,F8rn\<Ջ" 4%x8au\#=EB^V4^GWa[8nH`q$cE&] "V RMg#Wp?.`, EoYͅr( x7QToek’(^IPv=R{܏ɄcDqz!5DrZyș8ψZH|T, +'AZB`hhn(@a $+HR ^")ged!bF*[1DCVOкl + gHCc1>k-Y6rpeݍ{ dx:=Hj?.v,%gߎocO4eiOdzrұLqPfpuY|1%#ȔB<=Nvr:j # rțhVO܉δZr޷S5KE먚; yRFAOSfA[o@YxlԀBz,/6}P>mOn8>;Rt?6׈+DT_S܅2,YPubYy:3/u2[2y,A#68sZ7ačƊ,%!"X{/K/:jɿ`t^5]A D5=d,!dՎEY"%GB|duЫ(<7 }hb_G[#MPk 5L`˞QX9P;F JcxNaݏǺБw:Qm".dPOm~A61ٕ6ƀҐf<eZc<4j_Ep%״AP8h(, 2y^}H&1݉و%dgoC)kFH@{ i@zw7x Ń?P_(zH*A4餳,4rb UmBGz4EMmO?؁G IQȢ0aX9~1(EsM/0!\2>K6+LTF:QEv$*: IIhuAS6M\bFϳy\鸮tsu>H2 ?"ګ:s7[2]P`#v'r@q$uIg#OӳLFK:(}eP~+B5oP(܆uPX(Y2)OMf'V'.@//+V~\)<5h\0z;] &WAQ'/$H,rxD.W ݡ$pM$͌K2N7r[!VmzOVvGJ1lO"թtK/W^NהNzez+tcEx|'뫃^jofoB %hZ3ӥVod!ՅQ /sr8LdH&GکP5]ln4m):[Bf63ӗf{z*qEC+̨Jh u>mYyKzy2-[,%e[B&:-geX|6qRƙl$ό1l*^e4 3j6gY-bj)3X3K&B_[䪻4Sz6楎fZyjܔF-\`%OK [nW g5uu*z=SR6\_2SڨmNUv#uE~Qoߜ${@,UY ZjpY&pamڊ.Yh0Q- 4F[{L=*ꃔI |R_X49i/Қ?ӃB  {2Šg ~vM0e՚_L(` KW,f.3.9,#v +"v|#뀕ۍHՈY8 B~d0#11>Ց2QV lN=ß]#tY-E7ř#]0ʝ:4JTSFESޅ}=,)dhjLW"vI_h +|#+\K.lLH,-EJ+f3-,7zW]V͙ )XQY.f'5StTlmiF Ӽ,˰c(zAMl2~]YIіҴ7}!;>фKֹ>)(5w$hY ʔD2+ kOiP|:Zeԣ,Т 牵p )(-_<\Ekhmo#oQE32224ca 0t4 +c#!p %ECcI,i '"҈P edŠ4[ +>"#p0\J_*+,RE#E$t?+*{NBYV'K2B<2˯Hqnb 0tbDh +"2% F/TPx"(^PxjcX?+(ɓҼ-'?$4%!M3"{!ے"@GH"3iT|$,A^`)N$vl#M!A<%M34-%E멄2FoWCFܓCD`ze>s6$z7'i$zie+峹’- X[Iʊ@{@R C_hքt@ܕ|+2R`H54ּ%y*L:=hs4ƜaJO̩Ï=VLe0I0);\3eqt )Yi̛< N7zGh0`{Vѧ -pR[3C}$?nW +>kV5.cӡʅ*,Y%6rij7]J|!Rn:s߭VkjlV`;qH|)S `UFjz# +crɸq=o>{.ym 3&Y)r?+qm3!]3}GRƉ# +&o/4r%_י`O

fS?g {w +GLx.2ﳩw^3eFD-{d(PM4l&eOi!u]8%Qɞ80} ϤlI>&]9;PFXP=߿c:{TCϾ}4]gx80@ zG+bnI^%2HwDsLfBvޟ},zsG59 wAJ^+Ϡ_;{b<-l"v3582?oL){)ahchR'$5u.c:?3l2|]'є#E%ӢDE|%C:ɻNQ|&G%yMh<O \&Kkߗ0\xJH- $x޶:6Sj/euHtg+LуՓOb$h]ْVi }d؛/֌S;fqchWfvk1LXKzyXN!oj%zYkksfNYAyWGk[[ZڹZXڟ* I]XwY7օYf%6g6_ꜲWUV"6.O6-\I.-3;Uλ.w{OH픷^OǗXqf=Mi}7i??; sfT Nbt:_Y8˒÷t=:Ke #z9Tr㨋ζu<@pVn{vvAxD[ >q8sB,'Ά漿ʵ +5=pN7A2q97W@ ݺS<)WW1vge +w>׸q-s\3;ؕo݂,goFneݍI~|TNaVr^$6+=KysO58xn Z b >x#v~_pC{zS{7U˃=i\Z+]#MV)V&8{dh`̳=f&`4# @"| Gi?Ǜr`=%A,67f)zTQa2 + +l4Z!cCs^s1"z>(;k:,UXTTZW,OM\ڑwN4} guO+T ݢSSzrQm2Aa\>&6͌bs Edwt} +MlUL'4ξzgP'.H +w>w=]p霛s7.xFT_*Q+ +^o\ ! ՅF"F.9E#tȞ型b3 Ŏ6ؖrF^\̤b*Iȥ4(d{%ȌZIgs&_eQU=E.Ea*ڬHgwj4'iq4p\6NF\BD)SohG}޿Rg1osvg/٬1ҭeћܗcyK2+Uϕ?}9BȺui`A"ӹ%nz7ěK'][RZ%9{r}'T˦L)/TkOoX `0tOgz& 2I\?Zw|uݼͳP_^laYHϹA=9ܻϣ]Pr1ujË~&v1zy's`X{μݴҾ^=\~E[9ͷxFW2_jEjjiV[UkK扣]i1tsզθQūճ:JEj]ݭ.n UΗp>٫I_ Pܣ'2? &%} vzutBf(FwKZnDZq3 Y mDAݍ:W-Z}!msSs2WW+(sxLWzDnvtiGӉE MAQ [}:A,E[ p*N?!iXq4 e|Fzu7hF7de`?u[֩l*)Qt~,њ~S2HA pj~}Ym:F64SYdP a;0 x{ۑ۷ivv̫-$QsGmkD\y:4(tg ]y,|-L^E^Sore? gtF|#Gfs3yNْ10(TH?D(3=h? + +\ tV扉2#)/sZv3{]{vn"j[qhؽȼo{iR!L{D1le{31Z+_QnY $Zn&BwR%8?/2=y*ǵe[f9NQ[N7y 8⑒U[5ZƣK:(T_r*?NiOlb}Ms7ɷ +Tҷ_a]\(~O<8e֝tb.8ʣ촻WgZnnaiߝ'SQͫD#~24o/,hlm9ͺtGE!lUxzΦT&]02ɠhW-!gScƖ@I&G +cΦ-QIҝbMPgq ͫG㸶3DIw3t He;q0eVQO32mʝs/QݷtWM֪qH? ȩ$ U(ʏ|hq{҅(FK*6(9dԝ™T.V-WN7yu\B19Aoʟyxd:qI-]=a1 ^Λ;V@%%i,l%;g5e+z,gf\ҕ"sq\q?D_t/{!3UOkT˖ׁ*I2y$:J%=If.C_!lASX̟CDXiRh]_Ivǀxn=֍%Lrl\\|sg*M8bh.;ΖlzD')*T!G ҵGNrx =8HliYլ9B>Ӣ޴i~4j ':JKuFYM{|fnA \Y2h:NEđPSZAլgB>F d LHe6afL|gLmf |ư13 D&븎V*>w7VWGrbu{T_,ΐ^f_<־I6NC<_"0-l;,٢-Me6FdueU6= bı8Lޤ͝QxSe)Y'_DmZ ;e|΅vP̔=9) )έGk 6'MڥcVZȌAVMr7L8F1AB2 "p_Sx\-%<. +"ח09rWm;* 5@jfi=р<tE౲f.~srȜY/V'(Ʌ$r^מLH*M14 R!yUWRΑ7A"k0uRp٪RYpWTm c_ ycTX:&GfRGweHr$E <<'H1zA +3f+\d?d5w&[]~,+MQ#$aIءr1bFB6ZHf׬[ &h>G &` H89fWEV7cī,9t׃)+Luul5Pq3P4uϧ`> @Z=EM6G]%o.g{:eC8MMHCI)rZH|HU 0×tz٢ KVzp&3.ppj.7HՋo'A=a;m(\#iuV)~U COtQ 6GF6=?ͼPtf2vuJ^"6ql9zJ[> >kyBOa#,yEٗH? s݇ ڵLHYoE8}dSٳn̺U|HTr9b]<2S[vt5ntZ[M)ޕM0H+tr$%H=[l)&vS1&'h4B8Nwߕ$/}980_<v%4Rv'KnOEnOBgNL(TZ qM+,yC3󥑻!IyFHm=27cS] =:`qp]-OASn~85(&/J٧HbCi &w^kы U9q1şD}鶡1{Fqv;5dy+ˎ8yk=  +W CIkx0GLXk$d_yȎ%RNɞL5:Dv5|Үy5oK}5痷@N](g!J +=᲏}|_?|O]fz˸57^+!xz5z=%}Ť+a4j;-~"Qkv1ǧE#ѝcf +z-iCձL([l 3C3om1>2N{+XG{,D}0Hw#"7ND}1xLufSI %Rn3tx<+?xi [}"0S^ +Ynte`IpSϱd_wؗW9Qg%R@$ #4\mIJǂN|\=⚕n`%&jr#X/ Lg/آ@{3µ=#q"Jsh%d~.Rb3fρuDv#(jr r,u79q/[ӲJ<]TQ3s'[c~&fHweWD)?[V{Wž.&3!GS֕N^nO'ktsK%źR$ߠ=YƐH׀DM_ G׮H>UލzR$BNRu#`C.-}"uO +o/qsnzM/Oi=9t?=m3,[3mcC5PX5Fv^аs]b4,PQԾYUOW&n+S#\ HtWA>*)1|#zvjM[A|M뭆>Pzɮ]ծ}t>Z#8PjwpADiU.{`r8f*fna?$k(Rl.4i5*?>砶>;O@[ӭh0 q^&~Bn[3V+(u,?-im hpHp#F[Xn'ۭͤ'caŽt>i0K޸]Z'8KM 6QKdvb5@*tˁf`5sg!H"[XzKe:r{ !B#*"jWvQl;ͷA'Ą~Fv#P`{aP}$h? D[+=ϡ^"~^ z%^]00ݬMED#GO 4m}r~Ij|J<ێT|ZWxߡX8Fj}Ķ;Y;<‡R"̴\|tٺ_oqEcY}?<5?0O ׸@'C +#+9[wm\5^2 +rՃ{;渋tɲ "hÍsf{Ihc`,7ǴYg-Cmche:.=1'jc\y ~35.E-QĈP  tk%I ,&HeLŃR&S?B`~1M?%c0[qvY5/gX1Lf0>02$Qr\j.Bԛ%#ȉ#_28eC~I"odNZ󓝇(}1U7[!*+| 1\*̮^\/"ycLw6l*.u[e|g5og@U;qX}a깼zenoUfٝ)7ge4;ؤJ½0g2-uN;k%B(cE&s͌xOwYM{Dž^Ro + K&dK)pnTkZd@@y*4#KEyTIcȕu5/:1]`h.8=~F&GxhN|Nϧ5PaP9vZڜvhVGƄk'5ع_݊.(yEڲ܉~=v͕&=@DzLe5P|x6g:Kc'؋^>x@/24A#UwÈ +rml tԟ4+=w}>zq8eݑsZZ *剽K"pl en˝@mٞxZ3]@aSv\c8)UhJTx8uw/3av}*z8 u*Ԕm-=+{ +vq>'ǁ7ߴN YjPCxl:/ˇ{{'Цk ޿zma's}Q,hu\gѳ@g.H'Kζ؃ +us^LZe^v} (ؗs};=@%i> +z6Wd`/d/5Gg ƅ@ˀ + =߷f"@[gKh<%ŋ2bs%NP(`Qo?ӶΧ3ܷ5S!@wt~Sů Z)sE@S&t13X4*bE{iJk@-P[@[X4zmSq"l< }{wEWrG75ӗ2[o~ioCT +v{޷㭽W]+}|`oK9mҍnaLWlŞ EzNv]ַ׾vi[Ʈ[d{Ҿ퍣,}.6ѐ"޷ǘ=iKvZG޷D}[`m~%̩'-AeμƜ[_j~MRyxyܜ6#q=tۙf_xz%kC1S0S@AC[ ³՟7#txJ-ih>ƸΪ]8gY0ƲI"BeiBqVFWY˃--_ uNk8TC(ww(2"o?f+tcQl+]&&ӾPv fm vg^Ń^Es٘^sn?P=q=ר5xhɵ()@A7Oؠ*_:!x˺w~' ?{)hЯn~:!8uS}}jf+'Ǟ7P+:ՄIfebYՠS5AP/MhA¹[nWbhW 6L.HC'c;ƻiQrwXꐭTl˃kd}I}zı03#oa,8B.6dk{`H^[Y9ƥCC;qGAt,>7(8qt,@\upOtV1.$GuiP:.cWpI؋ 1Ѿ8fU>4]>vf}'z`ŹIek2Y@ X2)qױsgs||m+iacxvdx[9P>G d˷1|"ٷD. u,Jnn2Za歠)g`ORy>NG@ݎWeA-AfX_+cs㐾+|׹<, +J/< {v>{br}XW!x( !"/Sy.ȹ!;ϳKkmeH +)JneHƅM~OPyJM$H:&)U=r,@z;'w|ڲ{>'CYqw~*h<׷ +k;Qlқن+_n҈'u|3 X.n_xsl~=][ažd[KsxjyqmVѳ7NGi~D_;Oh~b^>Iҟl},Ζ}Y2*y +=;JSh=q|.G ~yo{9G~7ضF=4|wFۙ!I!V]5~Ńsvr +tm%h4D]˲F F^~~s`5ȟtVqB']jNE> +mD1χ8<Xþt]БY +yeKIkjuִ~Iɞ8)MKoD? mmjڧa>8~o/8-=l.^ޱ;jb |]lZC43I[mͱ,Sb58 ~ՊվhŠMl[1~W/uO?Zöke{CAO{/}ޡʲȂ@yA|y;s2{ٳ%d +"G Ⱜ쀧F-+iƀ,Hܯ˛_YxusBts5tm-W9CǮ\A'2y5t +jk&Wе穡\Aױ模\Aשnr݄jj&WIj&sy۩=9k&WЉ=6HPavnW9`K +Fzwu|caw\7}]]+Ƿ6}Nvȵ[+Z!`G=D&M65lV97xNᇾ5z*D&-)T2ǽT47H\s g3AA ~,=l4`sǫmn]$䬁AdcS֗zIӊ 6 qM!לjNɺ,gl?U>Yvgrz+B9SԾXu8ݷarٵ=Φ?ߝ0 ~q4{^[ֲ:f:?03Vr/M/lJ3>ٷˡ"׍`oh_sZM$b9*D6ʔY&NO`Ħd#юubo|ft*ٲ^wjV[NsӉFL6~g8{v&p1ǘbZksUcXܱnM4]T&M-X6=Ȱ41$ͪ'#I5rKSʜr&ɷAi듞 2ƴ8ϐ:Y=&,C2LOh,x[6=3?z3?\Äў'aS|Ԋ*ˆlb[eg߇k=$tyfczd̳BȝaZl<-{iSuNƵ ?ɍ:uE:q\Hk}UěE+Rߝx?$d0o!/,","bWrETram8o:3hm~ fC.,] +'xEepǏ[vOl>_9G)\'pP + ϞM2 ۢ?yR߽X!x[4ũAdNnFk2z4=v^Ͽn*ZdqCLT32y?hdgU~0DT^?]:)v<r۵`׿trDN[Lit5ǀ%b5 : aE$zv$Se@tiKr3E^ HN3="77<"׻лoo >i8x?n8?D wHq.Toq.n`9B1A_毦 򱿚.#穦`BSMrjcjByZW..TKSM홡.k^`_\5]v%һjqƎ..TKm~j:I췔[M2NV]Mjj:wo +϶\l5Ι͉/X'6s5UӅk]M_Yp5]([`PM]utS2# _QM:_T5ݴJTӅjzlb3n}c`ӏ)9[[6[ofy^]u1~霚S !Y9հHE9K* +.MLkI<1,jg'-) 0i³="Z]R79>U~\Z֤~jI~gz_5Vi3w}\ +$&㓛滫g߇231J5虻EYfCws?sgjߦ 00Dd`ȾT>Ο +ዂځ8Ϡ]C]2W^. *{-}/}uq]9B2j=G$ Y?J<A=Pʔ얄?E`ן"gz/Ƈ6',P3̳.fy_6TLp8VB2k6|2aP~ʾ7pq߭텻IsaEh7hKy/φ^% 9 w{wp79/}1sl41o֭|Lvz>W21#,v4:O-SĴ4̈́<7K}{ꔋת}X4̅׮'bCʘ;m亮 +n{͕Vp"3qʃD|;q0<5stw @ăg EԸnE{Ǟ-53ɾ-YokmfzqKy[Ǭqmg P<K\9ytΥ.|{O{o;FHh;ݹN=ʯOkﰝ~n矷TW&;exپڿګ"^N~׽{N>37ޏ?ö|j^>-Mu&?ݼ#*ǵK/>>vߜsk{{=N$wv61X"̈́?wGݷzIzkv])F'[셭Qv}e+f\?FMb謓56Uĵġk b689@7c naII])QCJ6Z,Н2HdhUy/gMs:OQ6iNii=9IYWGYw7+=KPZ. >PZK+'mI8-눫k//&oϕ7}}mQm.p8ibxyhy:65^˸cS3~}협nMVۄ6+^lc]H nw%|w9nd?_Yz*@9=ә  +ɼ,xmr%L5VL}ZKr_>MO;KjAmŵ3~:5jB)'_y7Pg018_aiqk'LhNlj-Mv3stf:ed^p:'v*5 NP8Џ'R8p*5>v`T&x.v*5}Q(ߠ+yUࢲE#Vn.7w]]pJ}o~ xorT(_&:ƻ~)yMFԑ9VɵWVf +7ykNuw@MPzw刓?^xor:'>~ݶh79,ӻ~gaMN, gw"L֫~9s05syJT7u!M~կ9 nU_SEձ|b.] ^{|g'2=) ~owY3k5߄hۣYlɳ"OLU&qN{or"8~v7H=O )bֽj[:߻~]Tjһs7顱]~s7uM~oUHK]÷^k}ewPrU.g7hc7Ĕ_^ZM 7]-'6{S_9ǻ~ +wlDh2߻~ CX~ׯIU?ɪ_dl)7Ȇ]M յ=~orXŹX$0+J?;x-v&{svMOv4[s0ݣ>;Otk_/C\^qTu)7XO|h݋_]vꏫwj?篟}ǃǧg/VtwƷ?_vGGwOVw镵dt_ߎ.~&;Μҫ{^ܸ/o/Yޗ΋=|<^}y9_h꭫oF{?-W*uuWŽͧc_7>6W4վ ԗrx; H'Z9{aa`O^޽JE3GN>y⩯?vO?'?XJқUqn+D>[dvoU4[[[X leݣy@sރ~u֯w˓]{zYƱ}+׳ɏjZ|Zʮ|FJr7מ\矰{,0nO񜢭߫D#m$V:>SIJ'_R s;zaC$3b/~wvŷ L{=ZXz q/?(,{nR xwjJOۛ?W?TÍ{j#\߾ww8:+}}n?/$u7[G|sg_}z_גzOjmӶokI\b_C-sƪ\f\Y/gpagJT(nn#^椥gzߥw7-eAXß^(K_+oĸtLaqSO`Qת^{v5>W_/7o^܊bK&ܭ8zgw~.N-~flɆ410^ϡ%߯#.OTq3ݳkO6ORVo~ͽw*R :9EZ}\;'qQg_Y߃'-o~zv?Qi__Xhy7~8.NӞ61saҾ;%CWV*zcj#-kw4-5 `_gD!+xR׍~M 4:k7CH wn9x?8UX"!Pկ~>uWmΏ*Yz1r}+W-9[3m1@YsT{W [jD EُݐX#~_뛻RUdK-cV\Uek+_w~pxmȫzZGIoW C^-r><2g*7mǝ^ة^{ۮ;xfݷ{S 19}^]?{٧뤍#>tȜZn4Oh_~zXj}0:!Vjbtmt{Ϗp'OϺM8V{9Xu͘y?>{QYx0 ߟzėV7hN2?l34ij7RlVCW#; ٲeOפq9_Cx[d9CЗﯮCxwQ؏.?9Pյ[!\5>t`y>$>ûi.aR-)Hxo=Ah hϘ9i˹.jmWE#׷ўbIQQQsVSud_mNb&I'!l{b3_#L?%B⟽r.wlkKS p4>cX$5E{Ҳ=G-v=Q6NXS :L5A˱nc^o`\G Z׉MfF\!kXrZAyvgɟ-|ޓ^=ԕNl[OZBT9DZ>S7Z}Թ[&E?)m#\޼Y>_,6ӯ}wmC:* +g}r!a퉾ny|N}ș Rq)Y*f|CqVMp'6~'S'6̆_cƹP[^LVZ;YC^om+j^x&~y.48)VLDJ \7_?v +y^+U)n1ϒj}l{gXݽvIa3/it\F +SRIy?:X:uR~{/G_΍<ܾqHw}8mTJC6iCv'hʽOO>nѳ]?u(Wԕ7^,oĻW_Q$dQΞ9yϵO) `W\Wy76A29,ߧ01Nv ?qyEX޸}p}쏓?8E;q:^7DRvoTv[z]m|xQ26)9V[WW=:kO쁆V׍$|?SV7IyҚMR*/:4$55$l$$5agi&$8l_5f_lK+]ZIjBZgiXfcPZWiF9ߤ5cE.M}LxbwO]ڹ׷܍lIDZ\ު! ^¼Ѿƽv5jy^@ԮN}'b|v"e"g"WTo3NVt|;)S;rWZ݃=%9^ZKk럋q@B(t^ɏg~nszm@l̄T0 +dCոɂ-8y&\54BPC^@N*ȇgChf}'W2.3%|ܽ픰7Es$My$&Fo +ym ԟBgXJNlm<>5SnʍyhZml;zoNƻ4U.Q߷6%J&ubY/~{Wّ ߽93'@Yq3A/Mh^;|4}]롱SoL%. [ @cC?1]0 /Qr nz00r5|AvQ~뛻񆷆l_7_zOYϋ['ǏjM|:J#O\ń5|WD򾻊S\qyaeKZND) n\j|E)ayLZl.&;yꦔCz <}z9TV(sYW9aC|% {oqR/=>a*t4j \[^3VW$wFbXB[?^Eoc4m(*!)߬S U6?Ylؖ#6Iz_(g{^,M ^h<9)ִCżuZi3F_?>38s%a&=?\/8y^Z|T07ڹhG>۴&޵yϏc?/^K$SA)"y9 n7#oqxg߼+%g>u,H_{t֜ :?EN1,Y9ڻ*WBCYg.R)V7N b5=Sk[崮+Qgˆo5(塘VWU9sնLxyxc+J2+ӬDdynt`.}E#RFM@mfs["v<"if&RU /._Za͵ڙʥ?9 }s?_ߣKjd'y9Rq1IKW~:kmu)]f;w?tp{ѹQ=<7 _֘!}E,߃?-<ɳ2"MʌˈƱUP*R~Qh M8K2U%~%Qb ËT?EG}ERd7_&/~F*x>p6q$IGITj\B1g E\ǙkAX'iKcfdX%YIrs"9$G*DTq^qt&C|(Q8V8T,8, 5I0/FcETN!/uU*8 Y2VSyX"S vҴwʞ.\N-BLQQFI^4(,JKcRAӖđE FttuȲ1 +TiAPO9Cp:qGEKd< IQV1 ܕ弱M\ dUFu8$%PT(rnJ="NAIqE*Qs "7C<2?H6q<8+c*r8cD3 !8 L^E `=)V + +Nb{1 Oa&.0үP1Rg%'9;āِ)J%h 3$NRp#.Fs\nO:(HW"8 x:m~ ShDЙrګaƁxh:Œ 6-c0瀗As;-hAq9Ǜ^=9B 42hf̓)aƁ +* `cPDz3LZ1fz0'Lō qNY(tzYL/.U9_ԕ0 q(aؗAq9ǝ'90!q"8`#ȋ^C7m! ۡĈJ1Nֆ¹evˠ]\UN_† kG ;<3(?BTDI.@L`b84ЫqJ(ikՌ0EO2O6CIwƹTq{u0ih1cbb )%<Gnav((%Y@?&)yH+r:. ^nL!h%4ԝ W+-`$UTe"DAYctjGHPMȨx0n) *ל:c8"x`xg՛ī@̂+ JCb2AqK:8? &CI\awI~:gBau׬2ZF{ j,S+΀/Н# @ ڰEHA^xE1BA7{ qAaZ`1$FS$= ;uC&i=.X]3 xPȬ;ΐ93 +u4A|PhQH^ b8!g>y^0WATsٕXAs%5`$VA?+qYTP9362lV +Fqn-߂JO,\ FަzxO› )֚dð*#puh S#Ub⼉rf6͊@rYuB"7yŪ(ݪ}3s>qXJ .q+)N*G:Uce f+.r(}_R$EjcwfdK԰B2h7vs(m3 QZm"f eTKظm8fT#~)Sg$t$Y Mdh]40@ +UWGl4j%qT|15(s*:0yP]:WC#R +MrAMBӰ1@Viԙ|)Fqv}ȌmRTH >I%tX+T7+:׸^󕸪ĞAF؁|%^VU%,AA9)cpӅ廄Q2qf,a4"I쓤%lt!*UĄVaLyVBuZL=6irG% ßaUO}t hxҙ|'A32Acހ.NgxLD'8Z4jK&H-0.\f1KS5q0n=KaGu$pp0m0x2hTbQH+#/Ao\BfE~-[kb*x͕TŭduHTtVu&j\ˤ^ B#2썡|p׳VYȺ= #d|hrS|:ʘ4\ :_u}`DK0޺oB6S܄ e^0'B-,:3@C4.yJg PP{> PaZOԾj#s*Dž_. Wbvz*~M0** ɝ{B[16k|FK +un| {h6O$<0XSE;5-mdѿwKj`!1JBT1y`Wa$q`Wf(C`l2/O +w-=bIy!+F09:qucACǺJn?)XWj FBb,ź2pI7XW1r+ +c]-ಀߊ b]%@pAjBXW] B]bƇhbS/U7CkO(n&+RN#3 uU9b昃PHl(]t +#s )PW9"wCT]rBhX!@)0#24ũOyPW 2UN k–U> + ]y^ y(te5&0ZчteZCb c0jGA+a䐋!`08 +HWשt!!#]e. đ3_i!T>!@15@ +hT*V ݚHWIpEDF6_rӕ B 9E*܍2Qvťf\S~ڕL\2;۩l@ +W + z-v-i5]1p\&QJm^ST#7`Gh8$ͱN5~ +}ZeT\*Fb'] +bz LGyJǃ0hJsU<8 G֘G5ؕ7eu6`W2Wѷ1v̅Qi`W#t\ٝR!4"Pni-0 Q XNW>50 vMʰr]rѮrP3Ѯrf]Er]ac3ڹ vƀA0e*@2(u5Cp,kI<,ȸ,$`]2SްMwHo|c]= `]߸XYo:X .taHWmHWm<7u*CMt P&tƟ곍t y50&t&t}A.B5p>5 Y>HWm<6cMjئ[q60Khxqhֳg`פ tIH2{`WW0da]=úzud]Ay;z`t XWR;x^u9u' .. >PW((aMPW*@&)6X}dos.ҕimwp͙MqH@C.5Fv0tJH@E>d+E .Ud@A$1|"]Mǚa|IX 3p&Q+M*1bt/HWd⺰CJ:E%H Lܬ#]M֌:P"]z9lKanA\.IHWL(LG>؉to:%'욒>54Cʠ{EJ"';j@t(iqS!:OumajЦܻt5::eצf19g\E՗=/{هi$8>yBY璺3{ xø/ arʈ c fւ"o{hy [thv qs bQ,4yY-0>c@IP Aau$9Pe3}UssLTBYh2\y-pi$qD?(ƒRy`IE̓Ay`9FЀA 27<)1OKḶrд#dH3gYS}̣61y ؚ-4R_y@BW%4{0/+rǛ,'4&-s100{?`qou-\8)Ĉ+!$gO|S8ug&|ÌƌmAB ~`i [1&@MP\`<\d c\_F!Ad o2712:8V0d KL(7Z氚MVc"v>3aJqْ`u(e 4'J; 2;$@<>B|%OFRl4){'Ki1t; ,ےю8}Ѵ`週ur"Yܜ!Ou1" ӪR)H0٦,؇F+0+h&N2faK2"YSI]M3 SI@]13h&]&dp"AFBld #&Ԡ@ +#rG|n($)1j*wC`{\ 8z}&A嘑zUhIJOG}[bJLH.(2B3!QD%} ͒nyŌ6T&hcx+abfyn0ܘce&AI21 e,0C9 .J3YY&)c 2Ɔ3|fdIA88WN%$-)4rf@1<~Jb:\kr!DҔY!3Puh$A$$%pz"~QQCaĒo1 fSo(d)iaƣRQ꜔UY prHT}yF7d%ma`Ą$i.%J?7rBP,$5Ըq8)aXѿU3RThsJ4Cw!\aXR] 2V3 +R㌀\$C1$xAd"efhnE,pH긬$^ p'MC`'a6X^Cgx63*Q`|B)#a=Ү p,B QlkzhJ!͠VQ&j4$$D +F!'-N4~cUXZ"/YSˊI%- *^Ѝ<ԋaQ0%},Mp *aiV!f7aܸ/s_ 4#rGJKitL05hjCZ/3Ձc_ 6T]FEkav8Rvka{3f/XPo"šm`s}-Gr)@yZX04m@ k~MEP TIWXLU>BT<*Z`a|̗D^"6I2Y D . :f, aZJGM!J5gŲjɎN4. +9Z+*Sh11|X_ G̣Иe yL"Zm`c< 0f^dE5nW=t)( %,!g=lgPI#[BDфAX%o7"3}r6' L9.SL5ukr\Ϛ`>d"yLԑ0g5H F$lR:.)ď,':$˥co@̰9RR+/0'R&*05![nXJ{Kҗ2 }RFQ) $1jtFA2c%Bƾ"48  5N2*FRNRO +*bD(\@((rMDŽj >|s)@[lZ0m6%gI5Bm* ΂)H&0*b ԗd, 55Oʈ\`9Zĉh,hf[ҟ3COpQbt>;B#7 FRY-<`Xb V%˱t_[N[Kf- +9=qRuDR8m v<^QEB븭8 B-},%&ɂ`#pf,$fVDPVRR4t +&=2IM1"K8P?יy6l&xH4h8 Q +X]vϾ|2L97OM4ih&_L!) +XAbgLKʼnS΂ a$]|@`wR a{ҖI(9i.Yy!dW6~b< ,$sC)1jćqɨI=ci ԙDcL8TX4`[igy#d0MG\`w,d WҢsdlĮw"#o6H11|I\5] t U61,ćN8FeLI`+4~)(xF0cB%^nIqllƅ:G[-oT0hs +AQInLfCQѴleVV g#!*8H h~`Op%Ml.TZX@ِ011X"}@֩&s؊b#"_JQ>sSʶeOIF!+С?蛩ġR a :4 +gZK6cFLXCCT1*A_ 0 >%)l򁥄2ZE(8YkS*HpIap#DQjJgռipMӑDT͉49ӑUl|XݖU!ʜ^y'c +ThJPA Dsʨ`&feDb6!9ΐ4b}0[2BA,eL:Ce:+)5 }u0V&Ƙ/ _CebT4@Tɔ?,UoK[`XP%+]#yXy~MD)'Ṣ#Ml-_%Y DV"y&BQGp%5Z}/GF#&=X ӆմc5q NyeN\pWBj1Bcp6iS`AN6K OxHF4 +Mppn&}%hQHRI>A}JX],m4Xk"y8]={i3^1 ,-̣,reLH3]]Զq֚6?/v&kBR^5^իKiO-H2IUieA/ +`ZaeIwd$J:?bᄢ +zOͧ?$j.cV~~wiMHӒfab=HQy+WT`CFNx cMtQM +v'GN_Muc2FN_~UԬ%0/4fDIAd?k>ǯLoI2bJ7XEU;#o +ȫDvԯft5ܚL R_P!/WnV.8 yT7"=Z٣ۋ7pßa &y=:=x{أӃ7lzv:Mn 8}s}ÝNt[ t{z[N8ֻy?:wtpH?.aN4΀ xm7fa6 Gnk鴛\3Ap^݀kNf&i?:=v0{tZ!aNݤaV,a7jMZ=: vCá|aGC?)#HM%Ę^2Yu2$=[2~/f.99>PjZW˩=s\!)Q6cx]uiز'-Vnf$%qm"i~O] vi3q;}UQ75#҂p +PiKi5{Q;tXpOvϝvoB*AyGtːj[j矃3kw$>7`etfAInN4ltB`~!;CI|;37$fs{N4 g sCfHcn݆a3/7dwKVΖ,uaTƨ:r!r:X镛6n\nܲ +紈#W\F 'lTv\$d 8qYmrm&!KJOz+K®4f. , dX/7lm5;#76!NJ4"VJexڄK rmPR%%Xm Wtk5wP15`MܨAv:0+Sf[p{$Ns،YL9 1e+QVe쥩m,꤇7p UwZ!¼eOt%90~$ 4M* vzCl#II&z^lnuL^n^e%X1T[X6"`$:p3X?iW׉VY8 endstream endobj 104 0 obj <>stream +65>DS9`N [1"/vm:d5m#&VwL%4Cl6m)H/>-[+{\y!Y|i^[@4d Zֵ:h%v&!+ݷVN"l$zq:d$v:ֆN"d$v!#ӮJtMBӬ6(;j{zWmJlub֝F=VbQm@MCӦ VҀ}|RzIm tWV{-j# #9hF*\K=|fx ƒ + +Z +VDK$(J1BzIމ!lGT'R)OA8P,FJk,)Gf Y`FncP2ZH[R;qP 9 3V OVϲ eTNW'&@;ćRvݯٝ!_JXgƏfy,Y +ywF=(,fo *LF:0/;!Mve6 NY^!zaslVr"zGCR$UI"2֡fDְҺ$"dxDZLzEr9c Tmj3X`V,gK4 x B3/C`MW0crr +qA1e}>CK8VxeŦ AE4˲^ULaUز#1؄:Ɗ_i|&`5LB [`⒏0D*.)\?Kʾ~l LkH6<~aS`̂M |(Xᐉb:lBQ;z8>5CZL<MuL*"HBHx#Or A 1ҕ, ScDdy?I6ŤA cشX00~&EU O束3"14>$ҿG\UL>njn"aئCBb Li +fŠ\\Kr %028=blrS6*&g)Q"a熭C 㰂auTBdS3g$wHD3BRd58QƑ%EvkGcSjiP&SҊGV#gR&6NpL&0ijOJ~\X&bfqRf6rwm "ڶTqV6j܋ c`df*A[4'M SbL,bt2 F3Z9}Yp&28C\6 Ta슠{Q202c&(庄zX2O\*3AM21K2R7G>sQ,}ě#*J+pFG-w!1VcƞUfXQg{d~msHɒYM %QKՍ$ɘE +"&Ԋl`a2m9wO׵/eвdX6Gٔ |0esFt_dmlHJ3WUYfvR!"dʤ ]G ]U$৩F#&R}*br'OQbO< ;tl zc-alqBԞ&[Tf"ou<.(Z)ZQPjgzoRVծO*1`5"wL{E2"r!i|z(e|L199}J(1uTLٰY! +DS$-km[&7\J +jEdn􆩣MgH[' Ȓ`+6/\d$AK#qy¤51IJKLLyf ε1u,ј)}~4yOk'قUI8 J}4jt>E|έ#J}Xz=- l!|%Q'I!7 OSKTx2.s粎2G2m)0쌬(Pi +kHe$;S!#Z!,nP-F$ĊwbɎ +|3 'g_l#31h>V0B4% Eۼ\Mw3BAo͗؈or4/eI|vOIFeJ,9 ς,u0rh3+R6C4:~q9XM7Y5WXG̐vLy03 +.MYGk%żlIoS}p5!WARΆ%]0A׈7j m|c ISk {CC2GpֺRU1A#vAK~ nH2J Q4v%AڋJ%c:/IHmքA0`1_7^5W@V+zp~lE{Vp؋tBfd/06 3h ‰ܒ+X.! \ثX꧌7RJKV7$c+X~Hy:0 J ͬX;pD#:H{y$5`2o*$b{̗bI1aF5q"& r)aHKrTƿX$PL1/E yR ɂُMņW9 ħaZj1%aD`@<5[ ;6AU$2ќa" !$ь/Ny<Ͱ?@нslq'WneW ye)L3W +36!Uͨz!|Y+dTS]H#SEFƱG> qe>)o| J-7`i/wTJ U.eES@юz4fyfS:^.3q0:)&+F\Jٞ +Zf!a>D<1]Ӝ-q'cL*y^#+O,%_?eWs{|*jڀFGŋeȤ)4.ݣlq4?B^״rt]r<X69ą +LT[jqWWe'n7K-1pܗX~3F}}a -kGirCzT`҆,NݰNPr_?(F6ے_d7 *rnpa.3"¿Ei' BB4hj?ɯE@Mva4 %7d?XxJ4w+CqJ%=fG̽A5܀!--^DC} h~< V*k y6A]5*6ȑZMss| OG##gwhL9lw;/, ؿgB ?"Z@ҫfP2LyEz$Hq*D{N Bߝ ]ZCU>ǠwA򳪥vD]8\a`{\T3=u2#MZěd,Cu̴+(V$Qc/KA@@t4" qsׇx\]U0Vy^&[7 Z06 a +<앶4& f(7I#DGK -,}R +R+r_b(q5^tX2`]nkjq}i:5Q=Ր,bZ}ɤGǒ%SݶӒo+ |3_WF4iBŸ1-F 1X5:{-ٷOOK +#uD%]@1b(=|ĩZ HH0S}s{wӣ7ڣpՑP?}/U@G",1܉io%K>n(Hzs}ȌZ0\ t礼gQݜ8`ZK~܀Ǡ +7Sfo)- 8j3_iջ4rmŭ]wkԸSϡ@Ԡe\v#|y P?U,"[ׯcNH/#4~5B0 7շRA~Ȑ3ɱSI !6G>@4J]5vѨg8{U 'W2lC% lj4~܉BXbr0Uġ[ * iRbvcwt e^EI7"X[*:38SpyA,8pM9j ײD@ i!AEDm &l"S_iTَ-n[+} ]&_kEE&Ҹ NSkpl|t65@g2AfP~.qRD(~ lQF~á9x79zP曈,K@MhO5x"c@ 4jg8x`¾dE﷧YKUt#ڮ[Ux2J7Ƴ;6=1q i*P<[\nud̵E&D̾Q6lj&kN+E>[Osx>oz.lK ZȡD(.;0AS}G\ +5wB8δd;1̎E7̎-\Mܱm{Dtj* ~#j|9ѿ5rhaAš[8Vq*"RO +c[Ȝк:l2ݽ9R /d +-c=-:푪zלP?cR  Z^-~7$S@i {P.Ld2UɩB3ݿVu+y'Qk(qgHUT+r"x7,N"KY:F?u'._GQ"#KSBؿ5G0a`% P1er&]9x8=+"Bp W*#8A{2s/MdWDq_>ۚШ_*#tWˀ!+d569 /"-LȐք gJc|"|@0!~o&?)צAބ/Hy^s@t ox0\M3vDEo+ a@c9v` wB._ L> )$1k=mǒ[j<[wa\H|\}͑]jS`Qk\Ѳɼ?RY˜mTľZ7;9vue+ Ϩw :5@ln/ꊻZqN bE[13l+8!C,^k/7դy[[;el%/dek &БOCaDm*u.3ꃬoEVYK@­bS}ݔ ]ڍ/93:/naÆ"mLrxF=nntB%VMEH>h2ԗپ#Bҏ- p+Gߜ`.$z<Xik +Dݣlr/׳kߟNOpC@.!?S&}L-Xf , Q^Qv&2uȁ +bSݚFlژnDR/K.ڒ:S5XS.rGk¶:ΐ̄b\G=}9*q^3U +ȌS}<+z%vk_OW:,:ڠo߭9fEj'!aO_,9GCM,7u^c>_~[t翆 zAA&q-7:- H mi(_l3Nf`Gue+v7}@F"|pў'D"iyx/7zLW#/\"G^=QIL-$<ڕ ʫBOX[Гj_9|'RkxB>vTTB1JE$ގJ)J%%[.}࿢(9,Etl_ %h%iB/oW&`I% ׿2`m d ˴`SeX[D(M 58h)+;-{SW +Q_\J%"B v)0= ಀH+Fve[;_9T#?^ٯ͊A^YٯfH:F~婾=ב@.J?0_QBl!Vk@ +5ա_ Vd9nJ{ð twDJՖ$4F|G1͍Uڹ mmWK҃_^Q^]+PConW[q[|ܽ^lz%X[֫\mӒzX^Qշs+qCh:wt^!9'zUbhb~WylYiό[YJBzeYyyUDon$ Bۑ4954w"9|r6Ϋu7:]rzU ۫YX +?{ 1qZWΫ|>`ҍ*K~Ԯ3^Ș]^6y%M׀ [A =e+܈*e/fz&]p)[Fo(͔^(7fFou~KP3~\/ cu) ADFBt@廦d@yi8#L! jVQOse-m0sx*K%K,BؿE9=Vwàdպ/Kr^$@ +$$wR +=daWPB,i`Sh"DZhL#ì/݃.4QF9Hb*']B "!w!1_o)M .}oE`:eoM!iFWYB:512rJ so6RM|ѹX~fE +*iuÇ=%_ў8dd(+$V@}"2mzg嬿!<{VݏÒ}.aA:cBԢ /8j վuT^>>#0+>.dYPR-ϓ ѹ-M2mƗ$4ky/F@P"L82CAw@u섵Vv +,ѝu$u (z䗎Z}꽓SV2.ⳛtKnb<#QD;&rr@-wZdh۪>[K&:Z,: \4"n@vBz;{ y dߪ\@J,݈'^kal+Sr瓐M%`M/ {鶓 <d}Ub~Clx-l.Fj i:m|hN+OP%C3n?h^n X@16&(ssKR(V= M E'H\|Eч5-l19H&Pۍ< jNJ/H0]1AhVo8?́sGvއO[DAU]ɠ}|\@^ +\Yv>=@t{!gG F/LLC7z<@8-dF݇adoHj=X<)Rc3|H_#bg>NMdS0|rWLc>+PWFRCz"DC qA@7H>֙sR˚?Z(,MX]eOߓ:S Oț_JD09MEa եv,K]/M5xI̓Eb(qρ6(da7uaVԗ4ȼ#ҡǸj@#3g/ȿI03*tq.{٤]ES&F(S 9d%E TJZ ^=(ǘkzZGɻ°܋QU1:# uzf|~ eAFͱQ}xHj3nTP9 9{nD!1PBMԣ9o$nNS5PjB %>q36e,EORROQ=fdʅ檄V<{klQbT: 6GLY;g `Q 7V8!HxjJ9vJ28p&*Y#<yagu.EP)Bvxlcq4裖?c7-bwσ $ \V2;/h[%;QD0{~U,Yx7:4֘FԌy3QʶR,"TGi tqRv=<K2Rֿ`PQ @TЬӺJs +#sHFN'6XS8e>OLe 9Z `oNLzMn;2+j2m*=az; MIߜM - IeTLOgCN ² |16GvaJIn>mYO *ss<0QHrBVK8g| *>㉡[k;K{>1=5'O !rm{ˊ.)Yȴrv1G~Ba#C6tcY&("Wjo< EA=2'ztzS"u1Ab׉Ar,> {:0Q'`5S]S¢ $DUQMdEt;t|A V~źϩΠ$ߞF渆E: C<9_: *k0ьr&m{b$[xTGp[ O6ԤI/he6Jak7KR8+\i"\/P[GKMAK*R='mQ?=3 `PG5Zij#r$Hn} +YCâu}Ǒy!/MR&WE2] MO5c&7E]pM=ǐYq!?wǐg`܄B4 2A-?xBЙeE `l>ە S̠'(t{̌na`ZmjCeMv-6IԺNQnnՏ:`cNQr!`fm wVcH4EA3bJ3[eH%})`G H_!)0 PceZ;1 Y}پG5,Fd PzI4Ml!ؾN}Y24@s"=tSn +x"qe(ݡRSя83vÞt Vş͍̀|F/"`ڻ!#u{lҽ>LN0 DߦV5%{}uJGYjComG{;d#T7S|52 /%h`MT3en9g?'ۍ_*o 8f6C~[]֨3Ō6=µ 54y5lII8oo2Z`e/>kh{aP Ñ4D}b937(<5Qpx3$'ʋq' z^5Uy1ELC~QKHLmsq̀FWmnŋ +{!cV{9fC8i7b{Q $FlkS'WzdL`vѧIa> KT1uz&楢d +IcABZ_:Zy\s$-Gzc"5 lBUPnj}7a[|SD|f~"Ir}I~PITu@&x1#{hQ`&!"\/?it`O@ޏjmKggksQla7d[*H[]¯~cm`3~Ԛ7kN [$grZZS-1dyZG'6tdϱh4s#`=BLRmܺ)@BP +M {K',d"O 5Dj5E; ۶u<1(q-Ŗu0]7hUJ^͒joK>HHa>FsZ:q1 ++[g0'W(9Z0W75tlBgki7Kg[0Sr/!&;B4$bzr"wopd?CNjnpJ|Qlf?!%E٢|'jDj-X>׻t٭$-AcE$}Tl>e{2#Q-m>͐ŗ6mW_#O6Ug[5Z|uX btu>Jhܶ6//2Z¯_~A ߋԱiw+_YP+ J GYۤv"Tnw }@#mxWe@ʹ_;UKӵxWI_6+գ/E[x1]':ny:I={tdl{8!) Ӥ^6|x=ɒ})]>5 % #썞 A7 myuJu{RHw+Ǿ`I ]Iy2Kw+Q(X>^Q7ЫCA{ +E5c{jG2OxxYu1W+v*TRQ}!թ`&gvث>L%;%N|:L=!rFS|j|Ps*bwdľ_EGdn%wZ؀u 3+/Zr<[p!ıׁPSW@:ӷ#_/^Ί|ub+WV^f _ 0PwȨ}w'6Ws1N߆|N}&MR jfRx(n͌WBތOYkRJ^ hh .ןߡL_)~Uc_4^7 6[=IWM~G j9_.n'~u$}R! +<hK㷝+W&' }U]WHnǭn~Kz3kx2T }q!#B~Ewwa| zx;vͧ,ynuXL z+Gv{A)/xY)ۖ^eav0R+h+QԾlnfnfGus`f{86; 8CH`! +`={il! +C {84+ph6 @éYca>ߝ +{87+pnV Xùٮ {86;pl60a= {87+pnVpùٯ{87ppV8plp|TV8l;7H i|a>ؠHłsG<,3.-Ϯ +EQ* wC:ix&wHw>uD'EWA[i6Pj6hؽwBrK7P +QbBa}ڠ {Zb{alŪwqKۅ(s6'T!XQT`{HL {Wv\aю=;0 +,C&"ZpG2:[&"c=d+2v2v3'dfO-Y[H!c>4{ lճ|BQ7hNK4ZgUZŝ #cc[mp*܊峂= +6INJuG/-2ڰ Xo98:7ll-eopfƪb/.X'%nt5a0BW潁C76![fd+BY$evW?_udX IRќ9y&dDAU\7bC[v (ٻF! +B|}ID:ROzB?3?*@"ތ 5PMu6m LW=} f\7(ݔvGe6L'OAWGi&}unI⪌rݻؖd̯"e&Rj_c'HU)>^W3I1RStD@t+&xV8a=`(t{0 F85Cdط%Ë'@ P>9չbwf3C,pIbu8ۅC-B7Sc)? +# hWcIn +{} W}ZBhgx}~b"QB>-kys 6[lf,cծm04XmmJZEYa_g_?w阾H&^x\1#BK-K z vE^DCPiNL{ f4G&nK+n۫D>.Eu-`7"s@V,OD9#*ji˶"as؉nf E}ƋYh4k$7Q+>ym2n&RmR47)k\2o&PnT%|2@^_ ݆b45>dy_Im]_, a"cI=n%ѪqL֝E7)eǘӡYe UUg v4 Ete 2̃Frt\ړLg1폢,#͕mlc'A cUqC6YלC^X`@o 3k3Oxd0i|=`VY?gŔ_ל%|;.ij:mJ Yסa RUc<әT O3nQf2"V;<>EUط!V;S7}֪\:zt=c;qbx[p鴄Y fMpf +a:f?,? 0XQC/ၞq5 +nF;j N)õ!4u^ێXf,A[Y&$GPϊ  mIK~y:Bҋѕ * an#k".fkxd0&C bv86fWoMI +Ki&sahPϧ. _zsS;m&2UJa'^Z$ hO=yo?&][ro KP4R^Qs`|ۢV̮:8u2'f 9Ӱ[}i޷A)=n׵:Td)9B{ 5V0KS!Li %t M3s,cNt8ib\ +3==gVIǎO7,0(imk.!kح@Ge=;i|8Q~B2 4_ /lDAZmq[|I;&h^n_m= Y/~\Ct +;-kr$m- a +=7}+4;Tu bZg|#HT{jѲ0k:״HG⨺SmD3H+Pɮ_/=%1rd 'ؘ0y–AgD]Mpo: n(! fs܈5k1Ѹ͐o:]gޥEE2%fÅtNy)P{'m+S"GNA:#} Io^; 0:+ρ`}+gy-[q#Mf3zb|ݕӣUg;o9 a~tkB$"DLgiԟ Dz9O}VsQ|WPݴVg)46b͈LG[Y$`#!憋%aʐ6sv>LFg)Y#oFf\f(o+%Y#HOy3hn 5\tN@" {V{c'0癐oD̤]c<8RCUx}/YPͭgq0h9찄V_Jm٩#|ԩ"Pc xl-DnkZR.w}gD+en~[ o +s3;c5j #JU .y ZYvArg+}=:YC;5pdovb"XSD{3ӳGkN8N~1W4ĕ"d1*=ɿχQ-W1SA];x=^м zcE [Ք&jV2#K Ώ +(tS_)Ayi34;enXǙRBPQ}_'OVS,`TRA*ܑzK +nDk׫vόwi{$#̘s_ ! ;g*1#Qχ$32 gsI5 / VYT`,: DʢEQ0k1E0+r+ +mPÀO^+Z cf~9|{p +;x=5UkU:˵v@f DsRf/C{/fΟg*Y,+IbI'L)-5X`(3ļTEF`\; v mϽ+ +Jq4o{!t}T=|kn$ +vyZr%Wa +B3E~`vA2 ,."췋m!XhlS5):EJ=Jz+=2T4a P͡WZ)CúMԭKE8)/w6ͤK5'ϟ!aLQ*J[*mwx̽% +*҈MP}jmSxysD2,rsuCmiZ?rol()(&2ov2 #WYU\ OK: G*%8v\1r77=*I.c->AC~ ?(0Eٸ6ɿ3(m٠a0'ofiu7PƺvHshm{%m 1؛GU=uiE4Kn 4QJGח=0~} qd=gVjg6w[5š)de~?4C { pۭKTU%z +9PaLtugh[r:1Ƙm-SSz0 dڄAx?o(5ٹrCCp +uaxa>9`k7'&夵o;.%rkH;` ŋyT.q9&=ѩ>E2r6h]C]h; +b[q#bSkܬ wn8'D^mV?eH}/M\?]oوD Í%i3B^E_BƷ&U)ܭO8(@%K@7!`*S)vvyM,xBn*̵hsղS+rvH/KQG@nGbJʙy`^5us2ۗw9db05؟o AvUbr†OM3 ֏,C$ovIHY]W e7[tluGq+1޿!-wB}19 =M甡-a[6Ƿ̍v9{Wz cwqj'e͖1 N /=%O@k)<ә Ge-rK7*D8:ET!xKy4e!Glb%(s_1@rn#MjR3\.Sѻ{IV.f ǰ4|o T|bnv,Vu]lv˝9ʯ- wrWK.a] F%|3.ahIЀ@R 5TRZ/F¶D-,ՀIvlG>&8tnbK`KweRٟOq +ψ J!T5@"yFQG༧NWW}?Cbt-SBbNmPY2i EJHb;eIUΤzc1<P^u4-DsYzt?%"|7nRk@s# SόtJ-Eoٶf8\Stb=yx `A/M￶%ooѷL,+~^ˆG]nm~&21}/4T``fwى/Qzh$, !NMxP)0-iki Pe' =jl_+ \5J? Ê iP )ZB'^;Ljq=xA!fs{'_:qނwG0JK^WA K.1oODsYEyJOOpP|14vB>X6{_p TC0Qvd:%n-2;B6 ½=9pMQ-ylV) 5iDZA1MX|ڦgꑋ(fzZLKGnD7)zb`n$Sy h֫|L- ܮLynfcIk +;$Jie:P9-R>5i/mC1?Y3Hv)+GҸV7r)%N4驵=K 2B(>ȥCf6YbqB.` [Kž7rZVvå[-kW +`ZN;TfmgᖪAۍ4"D 3hH̴Hu[{d$=]* TCYzJ-=XZJ +MWf)-Fp@,rDѶXJhObm/.XzmxڕX;9E[fB?t_Kkb)+n@!woJ=ڣ7D#h87V/>?K&   .LE@n^I[xh~;ԋkeq([RGHr'qDtRN;I0"E~I]`v+j|B&Ň%]+T^V*%v}?Ifм;r!ʾ>_b呒ex|HxDgnށF$af$,R<,ӊEzXH,foD*NJ0J`휯A~paQ^kI!% H3(O +)cmRšo~A2 % I'Tde/߽$䀪ao-[1^r 9P5|G 7(R޻,QY#G±BEu|u`0v\^9e0G}%Y];ۈCE^-у]h kG-i4qC#[[+1N/E^CTb6LEodCkhkb!OY Աx_U:Be@ڄPVb"B/hNI C,#^+"$:ǴP 0a0z6@ pDl1P)^L!=H̤,lRs FՁxB m}5|4!NOK9}rB&ĩ|csP"P-w<֤,hD̼I-7H,b?jjf2۸+zbY[^jqh]I!^mTNCj3\ Z k޾e5jO2SvEmfD_̵ h#crL,MlCǨV|m\Kq2?5[o=-{rÿ[W x5`^BLej(̰fn}~ i$H!hƟ*]ŀٶ=/QOlͭHaKAGlN;rJ7¨<>a(x Z8Inp__ޚ٬!6ԫxg͝PmI`>%DQ+cRb+3smAY[O¤Z 홍[PI8>8j4iJ?yyz{C*]fxB }ُ!&|m6xE6\ti4 &VZZjO*X!ogD3[7)ОZ]c꼾z%.p)Sy텄 JX0A} 44U|ze/2 Bf/(NTm+O!"gfiLo 3o"pABݬ\3AQi~n4Vl kta;T0-tTmĻm8L ~i%+e3GT<5Lce~֖n^I3չni| YTPx룓^w.(x/( +=E׻#PW9,fQe h $0RVhj'lm :oh(޶s-G^Da=N։TK<O1OFGS._bIsF"EYi/BXa.l,-ZݜF2\׈׿3Ov=v4Tg!Xi#gqm 9时(O?M%dҚ2II=u?16f&v)ât_|a+~*]ayl0z +&bFmeݢ-=G- +g>8@V @_;"=?&h |p9ʷeNl:X$OEOÇ XyR Y805(hfcfHݶHdкfyrVPKK.վ^kql^͟"wpѐHZAxWc +͝&gژKN힗%?5 \c" st-08{DMf>M$pRPsœAl˸4uռQ!M0$ag + :p:qc|D!.϶Z5޷5v +yq;i zX0LB k&bcΜ"`Eϕ TS5Ft<K<q:Hq>_fsA vpm<}:A~wo\WOnt#Idun|#ٕW3垩{:474nx~Bٷ0M[y䲴yM[HGЇtЧܦs\m֙ UKbWD4ݫ%έ lf% K$ꑋnf6ۭ\gU =^{o/m8yU$#zdؽ}ho77d9U3@eu*cejF> +[Hmt$ *5-bL2Y\{ϴ )AщЕj]}ӼUQ%Q]"t,>[Xx[fB(Y$Y9fRܗH+bMv0BU褧2ݯVuv6e]UHM-Ԯ$ gc3)DF8Y,K(-XkXAx29rP [ױpAPEK"ݭ!˫V9 f0Em M / aΘU0v]ۣ~Dnm1ϭm7_E^Uּ}7:O@r%m$UDn^^w\'~ JH# zEHsXe4|F@ݜQzxTEI5R:a^p?<2Į&Mf/kH9fL8%\EXQ{<"w EL8 sb6e@Af$~wlftqm^s{S ﻾1`n/ Ċ5M|[:om&2E! 1DYS[)AxA% v [@Omm3]@? +m}'͆#,fv6B>{{]µq Bv + U  &{uzEWфHAdGTXי–ʶD~!K5<] 6ݎղ07% +Bg_ stLs>>]PG7ABfс=fh:aObtig(Ch$<|OY|=2a~Vd,?(>˄OT3inޅ1Hm[Z@յD8\ZR{PLUl2 ˈCA\Y಩z`:?je*}tn&rtnj(\+)[:Q z u嶃ͱARy3 +|_ !-:Lo)ASpYril!J'M%d{7C㡩"?,e;4LjnQ.PGɕߓ95@ x<6zGOsC ў;qj`v:7hlKf~n@_tX㱁xHp +;w#@pNQn|T6|nmW"gVsˢE1K\ZyMbQiUClD!/Eױ>mSG QZ@(IZx[!# y]>|[]5&~,iPTYzEH kC솂G셱jp)mBF\6OlmeZp]!C.w9o4ckc%T`Ξ +}B`>f_lJzΙ_W~{i.ɷ,{ʹ {9M*0Ue4̂9X3:[&i2$Lm(Sϙc@GP(`jp|XՀrkWee^VZ_Yُ:/D-k#Ԇ#}mJf@bTӪujX26rT4.3KQG[v%<J v+L^G`et+1JMX&NBR7ጐ̧y)^jV> ?>2&-`ǚ\q+);0z^F" UcEP^% +gbqWswuI! W2t8hw$:~F3RS;9/%NaOߣ9)ƑzBZ@3]/Citwl#&V$iU^cMPmyh齚@-)ZasIS4-((N޷b7'JdG듵~LZD0mr-!3|k[B +^L,G?oLIK@L*1L42ZD{\Tfny@kPFCG]z<@?d OM"_HdzF-WvhQTپ9johÞ ۣ>6H 0Cx|1+r Դ0*^SԞQlApXm]6`omOhۮ:va4ȂxE^"rll8vv3$k_t%ys aelf۝o 0`J+SםELoR݈ oDiwtkZ +Dg&m嚐*n!/0sú ?R\.Qk|Z5f^4 X"ouewixUX2Z*u[e|t/ v_Ζhel5Į\Xѫgk0FwNAn<>yěagš5d_u["O{fX9 R[&Mf =@ +ծyENd<8X}:2`0>ʜ=Z yz2szmk x Α2(XcTPJʨ)f7xcavW[3DQT0JX;.z594S>E,+d'"7k_ï8>(=L $@8TZ1ꁭjLj w,NJ}HF DŽ͛EXa&?2j >*Tk+#cgLm)T +xx2Cj4(?Qzi3#..Z9dݯxZ/̵}i=L4+8qBz攐Ju`Z g@im`I6A i=T$HiaYAxՎIh2iǫF }h=R9jNz%8c_=_ iږZFq^36 gIA6e Pp{:DFz8/]RJ>vf` u\mƳ"vEa9 pVOlu7[ט $kGuZqĆWpl'^Ț>e bUPk/6PZ'meY'^%ϩBzI4%4'oX| }}fN*_~wm w0TŒ,nBY(rd(8G N >)J&&KHcSMqX e{72ĝ=X73DB}YptS/l]s2^*:<i9E|HQ K"otl=+(fz(-Lw 67ʼnS2h̤f8@= q;{ {OYOx*c\8ۍvXB+$HE +DA@fɂ]8 8 ĚvnZqLNvA.Nv[d]prK |7 9.dbfl[c ["xKBo ~jw ZVys@&,M(_^e> us@&Ĝ_7A E:^7AVݼ B,Ay]񺜎9us:fxЮi<&|LXw9 =_e _U tuo,͚&fV7! m ٧ې}OMȾ V7!X]lW]L 'LXMȾlfs/X*M%5bHU[42|uq}TsFqj+kuHUm8*F3@M'Q]CQ-k;!TU'JFO+@PpȄO5ßTz} OWGN]kПNxj6CSnJV/xCS>\T/dWqW/`*{wYpfWb>TjuYAu IG+҄7]fX rPHO|ďxMGVi_hmvc',ꦽ]{oAm7 zCEl">&j >ZGnK" X'Lv$tkݾ1g)3T{'@Pk{0wAZ^w-݃֝~?oE7jGj4g=]S=B<~ȕ"ݸSirkܩ^>'A \*V}KmfQdo-g_ r*?c q PC{IgiK0 n+yM>zI9\} z_yiՒetm™nҵt-0SJrpeiǍNdwenl1ؖ7tc[j[^Ұ-tc[pn͌-%=tPw/dih<,`ն ngƙ/Tij|a[LƶLf[Pd3!J7(w474IknKZ]u@?v ޢ'*Dx!I~9GUj'A`+‘"zK{D5:aS7/Vʏ~үX Aڬ`%Qut..M.NM^pqŽ 9w ċ/赋'Ʀ/эMFΦ0@FWn /1\tbAn V* (1/3D YRGN8Ѡ4Wy;٭KluB-W[ FUS3UfM? Y:x[a ǫ>z 2oN^]]qA(M"TPw9C76OjQ+J+eTA%=MdRNHC~{VPtIT@eKkvNb{A댄6 a'UJf3"O%@Td:͌Gڪ вkI#b 긂Fur(LV6D +j2a؜bF[`ưb~e;؍$fDe۷m5Y8Ǣˊ7Q_bAc85}&`W?wIGPE)2 #\b2z`'VjL} P@, u5޾Fd5_ Y6]mElO聱{y^j!'ȩyN6?* Q!Җ^WTYofo%̡9I4V ѱMDq8Ԓ0%yVeRrhL6qAJw6/'=tW[ +A#Yk2?iWY'QERB +G%|]m%ABBUl>e ?:>iDs%o,u@5 AG( vP\%1KlپeH$)fl߲f?ّmR˰2,;py{aˑflF6v  BH +c)Zĝ|}$ ++tC1>{(O-lǺ;WD[,ɇAh< UdSmhwI.0\ +4'V9vpeуh-Bg EՔ"j61Tvbf*med`M\ל%|Jtf8 аWLV_xiG#xnB:x5OHGsPp:"Zh7VWpb"yuɼM) P8;B!x-Ki >t9׈bķ:%ifjOl&QOTRU@I"BDOV_$M/\h,Jǥj`H޼ze@%be3z_(3( ʽ r}evqg 1-_8骆߂NJ 3{d]D/Q9P5TM" Z|gJ@ܾ4e|Qut! !U5Phcr5lvd맍+5d&-sAnqD3++hשoŲR|A40)iT Z({"%_]EHD͵1BzYA^u}_Nl;Q߰(i^#nGz͟H2uyʋE)QA2S̢'_9ڬ8B㭯bfJ*g4CfUg j +rb(*gK?H +{JUEHB\MdE{fl|4|*_>~j b.f(_I ]T5u>`ZG71'^I4wu|d]xt.oQx3@9vé&JuQ5ZFA>E:^ϖ,50r9P]BdQY􈴺B;<ގH5I.Pwupɴ6%Л@ahWdTZCҒKXC[uLhLmpy2Bx FXp.`^wWKY502C p`mzT&n!ZPfI"j\Yk~5dxfJExUEŒݘ@orkD^1 v I XOa:0Z{JЉ]aV.c#"mͪTcy"YNz;88W;L2&8F\iu s(Y;'%G}BD˽]m˓tsTl +X^!f"^c0QVkN\(Pdzn1V&7'[[ g/^~5E +8¯ǙIW9&;zWU)+ʔn* *a z~hRJfIt^Ykv5Hr{?wk&3[BaX,S+NW>,˭ЪTWQ7{, $U9=nme S렝imCP !Sr ` lo|!>l9z`)ԟmc9/wg Մz}ȭ=URy؟Y)h8Ռ3D&JoS@s<\qi1Gʹ'X~mEL+BHeH(Rg(r}AW@dJˑA}qLBc#:eiB˱irYj+CZ(HҘv`H5۟S9=kcGu"ww~UN0,ESz,+&Y/!uݤm" Gmѫ8 ? #'l^vS/eEa0MIX=ǣߡc) g)Bm +^AG\4^3ζܾo@>Yi]6LڑCŭ,D U?[al %M&&sM=YL+íR[?vCW`6;Nwwn>\NSԱZx-h>K G~z,TIDi zRWh<aqTP2,7s +{"ڂ 3ZjG蕝V//KA1fD>ڀ<0ZLA!R7s\83ox%Ev@+>[\jhzT ^#ht'CsL}$)|(#g?}t^9wS3nns)\a}랫2n?. QV_l,޼G͗gK?U= 2X"vNxE n3RUQGN'䤽K%YE)]Gb5xg0EL@z@5IwTc +kE~XM_$za@Gd'յJū!~~h[sq̼KK&I~Rr /90o3 +ì +QKއ_ ƏANMFi{Tv--d 1Xzy?P˷'f}]SLWhO +iEB)%ViηT2}5 NmU|K~\kY.yp % 9~{z }. uфu +vY{4ha~Ihlj/5Almĉ6:{e@AgΠwKAAn̓7lkq]S 4k6o6 !( &I蠈ۯ0?$u~)6gzT(x<Y]kڰh\ml+4FT +;hX*ż VYd{<#0Oa=N1~+3́F2"olM ͈m"l T;DCfW +,V?0 +(W݄AhQ` -1`J{q9T_HG:fK*}SEC.?E2+ +u(HG"u{ơLa fn`2TŽP94 ݔ .3(5C%a:\p&+.GI+[DOBZp(/+<%U-kxPqwvl G\uBv5I*ZD~SC#؊lT``֪7;*@bZ~vAc$_`@<F̏p}*Mu}hI:|(\ :h}H#Ëg~'"kU 6~|/402gwod""h@<4zcn2E2ekaaF:GhQ4 x +&/ϜD%t jC;uܫ2Y1gteͻ^dU`ؚp7T^g"5OM;4QIzi0R1x+G t{Tl.kdUzkN4#4c3VQ5Іl0pGz^~BEXZdkz8JQ X:(vO C0 +-,DM`3r| +{@C#АK=ˏTƿ:K${! (Ԇ%w +9"=e6>|Dg+-zNG8G,[.fxګ+x5bYW@pѸϲ)ZԞɶuCȂ-αQ$ْ\twHc½Y=qe+pL=oX) 0ݻն<կTZC 9nա5cZWC=y^=\m+ NT`BѦ0 +1sCE@J"Pwra ]&ZT`1z% ,k{r&{s{pyx1o)L^S IAMKpFظɑs" 4u$׍Ιyīzz4`|/w½<5\.KC/8ct/)G@f0Yط5}ed\mU`ZpnMDY,U3$)'kBlg,kR-McIpPS"Nx 9[kɴe5?5:.?L5Sahs+uLߥu]ĕ4`ICV~XfYݙ',$$Џ{I}0X&E^,3)݊yrf/PX,En,Ȇk},sw١Z<},szf͜sB#߾e-f>>K7p3E{m_`U9(KNoegb mqKt(xܬX-5f4n y,r;߃ЧiҾΣfqXF/do3z T~°:n4zY2^b-%K[``4ԗ;=0),PN%zڐ^QZrT*^}^qy=N|$nm3ѓ(|2I cdJ Wr ڏާ^~~Y~0 {v<mmk(cbky!V 5Ky lA$Nz KaVBu^5kFCn]@[W8o_~lx&qBMFnZw2/M Dz4k#4 -`9 mqAj >ҴGt +ezR1 -Hch]4c>p\a1DHssWQ- <#{תҐXMUf',ޕHʼn͙Άiʞ#@ȑFh%Q'i$=m(If}3x_9 +7lwů,r6W91b_jL M7(66ҿMʲ!nyפ"HS yݚ/.5qGw5T;N?a ߚ#|4]Zڄ0Bu PgY"[{/b*{xM8z޲*KyƤEvKv ̼b~Ct6HC֍PqPWxfx7U0:iʺ12}qͿ*Tpε __>#頻p>i9V2hyFwu7Y9rvgNq w\ lQ%uh4H0G͵B!!c=2)MEhwnޱ$k3(s[;MK)3 3`۫L1BՒ}t=<|cI]Pҁ^MXvIy?$vj:}#( +5҃}e;+F "@'%djo$A|Oצ5sKeg=fbجX7k#ߣ%|:c?xרNC-D+Wvդ87*BeGo52bFkqg(B>Au\dev>b?kECԌƍ}eD2Qx> +n8#_vGp!߹-4;vT ۓf0GE{~ (ԂhҘt ~0svg-EIQ u+@,"c_x +JO3 bs(QHU8L.8L1V<<ZT_G6De' TFYhIxUJ$uty :46;Ľl!w>=G2]w-1[f`#?`ae59a]߃ZE!d1j6=͎ 39J;QWիMo\M@}V,ϺKx,UqmQ~ X +Fi;@jku)>R.)Ou7Ϧ0śF]nѕ(P2J5},^M #6gk0\: i52On+Gzu Jf8N{Q$(eb^%prsެِNͧ +bhzdԊK*[k؊֍gWe-xp1qg47$jxlۢ'0]co!`/FZ)m^DR\Ntœb5vUl݂%5;m ^梵F 2ӥ\ME0^ P@ɍǛvvZ5BGqu +__\p|^9Ju8Ess0{7zbTr==w=7(YM¥h5u*#x8 53Ƿ P */kH]S 8*(I<9,gEM!=|U:zm~ G3ֱ7l + D +BO^ rk0(Ƴ;@!q фdϗ .@ w?|Z6@}a36m {Ζ|Rjz5?w0HXTo~=Q;M!ũEiL#:Ҫ<*RND)XrNH*N$\D14ŸEА \Io=}+gfǠdNV5yž"xkwwB}Q۩^ngH[8n{9kǨ?CRYyU< hzi)[^hqθ8:e|k3cD(FP4zkқ>UxGnm(,ܰ#\W9ThA} l8l|!K*k!TOE8Kmi+^`#mI/BU؞?ķ-C +I&@!Xh/^1$p:|.z9E)Z$Ϊkj{RhGmAG^^/_)UvgZ&0Ӷz S +UE5هd6>4Ex6lהHP5ZhCJg?Eq +CV4!=2,vxv\%|'Go¼"k84 \+Zy6]ǖ@Q`GU)4ސB ė($0IWkqv0ъLWx0?^+6P9>Ę}gWfzhoD1nDm>ehObK7)^ +2 tz.vdPYG +Qՙ,SV\=z{&zR H=z,tS$Y.sQ.*SF@cOUS=e͉v 5 H82ť饢mcPrNk%U/9 N PMF,!Է s'KsՑ0! er 9 +擗֙_b.˂ +~O l/K9[5Q(&e7yb.<) Eszn/i3 gyiJg.s=!$Ou"Fo_/]Oф-詒K-9vdgD)CeAȧP]}b#ڶ O/WaoCmRMFp +c$g%RښɫA;9eA{H=K, 9TA;ƽ&>;YR 0mP"a$ڼin29و {'ɲ-V$zuUDОiPE+9D}tݻ:.db8x8ph+x_#⤢W=P2 1|\bYn?:;.g7[ڠe)9z@%̎NDl JM+6d|@TByy*d·n>”җAoPuH} )y9n&,Xܴb.WYУ*Lfps>*۝CiidR6;B@DOheCmDc񐐷#4"/De$ vzh)V& D#Ftj 9s*PkysӊXxIT b'8Gl>sD+w + +Z=\b~>^uJ!]DZK%A\j 8+ǴuLY!d6y"uM$4Pδ yjӈiϔ+=t{{Ͳ#BӻHb>ʻcҹL)OƝӊm>_e OHHWbx {DalWʹIѧZ"7ۘ,3ZH&c˫VlC*K:-*kȎQM|ݖې=Fdbjs}RF +$6FWY6fG;ոr I7!ͽh[*3 V\_%&ς@ 64_d>z\4U,U.~ZOBZW^L9, jQpq ?kR?.ZƓp0%qԙWcߪ|`EU2s-v!Uۘf"^qqf4Ǐ5y)jh ۻLATxyI脮I" ޞ]0"IItm]GiF ZVu cU`[{?EKڵNqRFOi c%iC4d=1}rb<}֏$_/fK" 3|;;ű!YsPKpn/PK=KG!ЫIevZMS +aj%Q{U^~Q&Vk{[vN[=tҺ)LB202֠s_p5:]Ndz~4gr w!Bzs7xFxVߥWog v " +kt|vO\((V}Km3){7󠷟="{K GNM|`7T蚟հ.P]%tSvz%*)ʞm#{;s}K^ v)@Po1|L +ЏN;b_1dN`醽~X/<x_Y̒.풮1b \ߦkf&Rs[08#r4SN v/@?$maxQږճ4I|.S޶PW mȣj[@5!X=iqIn0q;LU,;oĸHMS %ɗ 5]y7[jd |-Y¶RykYzd-Qb#soŎh"ZlK-&g9Z6q3ptM+>S\zofB9% !ZoB|_*b,g}>stream +%AI12_CompressedDataxi$u% ?|@ v`؞FJ"XFQHUleeYYp~s/e-]U3,mg:~_^?oO7_Ǐ~_\~^7>L=8T>ۿ_~/y/QW_D|/x?ߜ^eqy__ᯧqz/_Q/~Wopz84}W{m\и)gO`|(n*cI8ygǗ_|^~o_}?xs;/opz0W_bzx!yL`"?Fb;m~r9WԺ2:FRM$$))PʁsTb1y;XTkj'w/;Bc 7r1b#BLɇxEy‡| +5G|';i1z#?%aatP|N56]g1NJT=L_߽+3l˶+R-}Magcb-w#^a%=TTjN?I~cz_GŎsRzT<9rc]Q]?~ܸmqus9/o߼ٳw߼~߼zp _|>_Wq/{y/Up~/-?|1 +g<I+X„„@_<7niZ6-?{Wo~=~do޽r9y:K=`qNc~RaMfo0nEhƇ /r}W\ޯI߀~|?ڤϱ~9j} oo cKyg/}W0 I//?gviwO/Ķ? >݋/}n|wXwM}ۗa/_Oo?v/޼x7OV_?r _⏘]g>A/7_~=m.gw~?{{o_}YשWxݛxcrq=)_ׯjxoAS^}gˏ8k}jsیGxF(/_=q?xWcWoĘ?I|)>|-?;_9ү~5͊_ͻ_:o޼ +!t8}9Nɝx|<^x:X%1=ʄPXrF9գhȞ50TC\#rB9BxOFI%~Bq(c~DK>|:ÈQ!q);1=k:ɿ ?&w3@)HcGsCW?M!/@M[vƣQP8AR8y + z^/i/ y/~B9 &1 0Yh3r1G}/x/2K`\tɗr).rEyW2qz0^37µ^ˮ+DS<>(ezmk.rq1xľƻ=N㍢s1tQp"yqvsYb! L,_gdW\@$2+YLPS.+RmQ_P:ո|<"nS-WY12{xر#ppb |Q'p L>qzy<P0MvdUg&7\4mF.V_T`7wf'x?+J_5\,ኃc)kϭMZ\5w6~h%7ejŷR+e?:c+Vsrm屗WLsV4QcLx͂/ x=[3*d將*aqq8X\#ѭ-)3W\5G윾szԙث156kݪֵԻov%4aLw-O,76b&UL*n䊅'ܝ8>8k:M5!1~W jgevExda$"C=ϸ~8 "!B}`r }jrH[&GSL>hb@|&'etpQ"9ǟ ;UJBNtRplgo}mbmQ4c" 8 +jE1]ct;z^ naJ 1j^!T!c!kq392BFP :zSguTTDOPYĮQZkۡYl8j/^J\7^gU{m1 0xi;Fm)0glJ9/'}·жEY+ϵg--xR4,nJfeߢB9mTw-; 7:ԊRUq2mߔ-5nJڔ)eS{v]Λrٔ㶐w+6e)aS iSMr)'+CyS.r^f{GѴ}RGJH6<ٔLio*-գcSklA1mSn4pwެ]7Z7AWSfiUS +~C3~cpS:>Cm}_=-bۡz_a[Q&y56{Pr跠Y(A7 ϸv?ϝiV'N~v}Ӱgw0ŷ,Am\/ݡdl>N͇kVV5}\[mFɵϳƢnNK] AS̛zjv PYK?&红9qd![ܚ-$$EHMw%M1ϱnṄf{9ڗ9k7w: mAs3ۦ&b#c8J[n[`X2ON_Z{%g׶֭#|>ʦ~f6nn%UAimf#f ϳ!5:xe+<դ߱TMNCS K%2~.R x ة2$ 8e(ܳCfO)VdO +/t̩sY6^gE=le?yS@YZiZB.ZJ-mLW Z9[;'ۣVY&Y'6'-iSfuz.4Qભpi,IVŹ m.}b{%4]Mm8C͈2.s9xSN) OeYٺmMbaahń w1/~ƺhy'iokK86ΰ2ǛrccvS#R~ֿl Y+zƔ`Ƅ {ͨP6 +H#["lAuv}^Ca 361YgedYҚPA;;>@`u襁AКB~7 qkkХWw{^+js +hHkyQ +Bs&alRzI3Y|7.f/'8;Or3Yz e\VfJУؗhړLyh0ݓgtW`YG ]orvnz怡7!%&t8JFÌGq3:flaЙZl-m5OCCԖKxm{M6"K,DňEXEthB0  M . +5^/iR̨c֎1z}~:ֿӣgi-x9x9X%6*dkQ a?WsXQ2;X1_:M!'ӯAiϲ'(GqҪ.e_Wi$kG RJz32+ړgӒX _t{UI<[r6Vifs4Q)\7.{Ek0ɴqtw@+r1.:YWfے͓?^7ˀ?4x*VJzץ†WéD2T ˩Uŋd $΍.M8 YcixX-jnZs +:g๓8QܪCz w!Go6 +"@~+z +cqRң4Rk 3v ^\ӛ1aW3s]I"nݯ\$N&YjWYdNNec"#>88ѕ$HGN\OJs35Rp7C-_ ̀0zW0K "g +{VW8ٺqm&rk;c|̀Cu m;{ⱃ]eW|aݜ1Y. )n4v(%^te5Xh.30-djf`hzj~LFmAD}ZI乔UYV>HGE";[/#KP>\)cq>Ev'ݍJaQl5 #Y=/"ٕ46nssϟͿЂvoM7_woNYח[.׺|=nG=nf_";\|1G5ᑦ#dj-(KŨсz% `\QYKn*IɱYэU"0Yfþ6:tC7+?[p9v-9 M6גּOs9ΥΥJ^$>ޔS _Zǔ'B8<]R0|/3q+#T4k;ǁQ4@ׇNz;~zqO{ Zv76aC>􋝱oGX:{i6^7D֌2tjXrol3]XuyC D$щN)jÜ'N !,|V 2|zSNSk/EЇpy!Zuig_)W%YHRM w v)!-Ôa)_tgllũRSOL|΅0:yK+4gqeF,98:v\2?`Co{oXn?~N$)H>ő|#G)Sɧ8Oq$H>ő|#G)dG)SOG=nn6@:F-0gd[%|=3||ӻJzhJ.HҤ(wNtwc<:7fEgf YtF.ɬ,)ɷ*,e͚~sz~6鲇C~~V> ѱѾH9w{ls*04k:o(Z^WԕUDS1EW<'}KYnt?47),J>>)1aJ%>% L?E'7oy<ƂU`~6%9J0ߡ)t)Yg<̌||n5l/M~69o3}=ʥ|mDKa\TrS Mwo&X׆ru]u!jc-%$.7N&o UeK ޞAj̫fd O x,mxn4R rݤ8>^YF[{_K@09rَކn!Ɇ[p^saOpmPK3PW7#϶ԯ3chX {e7q\{yW7%mJܔ*+Ьao*~InFV%::m9rص[':TCu~ 7Fь*PBCQ'Jn2&-}.ܖ#8?uv,BmȒ5YйNVy&#>{I=ۄ:7G\35ZĄLgEO0̀I+WJ.=<)7e}kwM7"g0 le}֧]H=O{9@I@=q B؁!.(Rv:44$rwA_E4XFsҕN.&i(NL()]yCxe%e+c]N8)#YIveL^CQ>n8n^!#Y,M]0%:qoa{105٢-DmKLgG7>tUxܒ085mm*aJZUݝj7:R̒}d59PO Q|]6rI/txojemt3Ab^k}imVڅqt-P\>PfZv:lGXi\ߵ{プ~S2M5T<V~tl.u|n:qEVVH8U:}#ʟ768{0^y=\) ts7̴UvƚΚ֚~KSO oT wW\6Sް~l9Uvu:8~ص9T>17ts]Ëc=DnȟneQ534=t=6R} z.3NIo.f?;YPɌr\a\O-龘>P~V|3Ѥ8pޘfF3!Ȁ ]AږͤaGey힨ni}q+'}Evu@&]: oMDHWnQؓwL?C4~l p+Fq;mڋ4sH&Y2gCJ(O9 +C>:b T]jx |tޛC KJ :g`؉"67"W e6i3eqϊOo];5u|#>ӓAbabM&͍atki /mο!;)ogTCWt\йSZՊ#b0YifH{`1WlT-n՛!l4-|pvوCy&DAc%5z ";‚q]0Tׯ#>{+ƣ? Ǖkwk|ÌB޸{Yfr:uPhSգݻlN4-/\3?*>sͬgґ̩Mph,?CWU`e[ '4{c[$Vi1u؋~\_mx8֡So r#K{:̿coǥ>&EZ$qcQ9E9qm#ix6eǽ\#ۊ{\Wjpw]EXث2"Ƽ}8msk3c޶nqnMt*m6ٶQlk@v琇 oմ~|?޻ƧrsO>؃16{0'W+oPZl6'\a{n.`K7@UU↯.QSCş֣n}h»}=}0ҰJ&?0NU]V Q64z<ۡ:@wyosoຫs˾;}@M\/D6~rq̛:u3+ޕ= rg8-zp{gb[|Ƒ\Α,>GڴQtK UQ*lSvmfY˃'"o1F FqS*t +%aH#.CTa)G3l(M97ϧ{eҏqQ&~cG֢@8ؖQK!Ħ]4 bļdYpV>\Kش#-va-N266ݺڕ~/gw?SOi>SOi>SOi>?SOi>SOi>Srp駅A|JdgϘ.[*seI8|"]!1׆I^Y 'tRuf=+ILH@[FEJƔK7,BI#iDp/l"o&-1p+&@} އϔdIpxwD9^L{vu2Qa {mj u*nG;͛ڳpV57oF$źAXvmB%7`oV!.IFY;cV'KXdN$TRaYeT??-֡yWOMg2rpSK857ih˽F &rK'lUF + \ +"Pf2ǡƦ,`!KJOq:M]*îb1լVmuaTmթx .Cg^t.+{/_;0˭Sg׽3zx~7/s]/?_kwcmi+aM>qV&góG,2(u~{LՕyw6]N YZ~ϭǯ+{ެZaL~~m>opk-kb~Xȸ=e|guәVaǡ >ܝae]ޮ!U['Wxv&q/.q7 CF)}I\C!8HKN!\□n9BS]Ldˈ3wG 4?1,C#˘Q wjOa Ywոo]{_^7g=;YɾS>mailצke4l6 -qܧ[e- e'-cTgU2=%|_:u_W|mI[M5Wq{hcE=265RԶxK@{QķVD<>FgVGUЇ~vu5칫M1̧;<5Q̇'5s7eSо~DzCp% ΍|\g8gZP-VC%[/:Ch^;\/_>|~oܠ+}Sگs98шr ?D:l{\7]YVwQ0}]+h@}x;a.f΄synч''A7ETM8D,>|$JcRB +7)w} :EcRR*| ]Nl Ve' ?Tf ?`2={A nBg ,)3oK..mijE.=xwZ{_ߪ~-UNw/W}XHeor{ 5VnYQFd{eQFN]RVqc59-P~UK]tb)5Q^q3hq׶ EzX\+`{t^UO쿔'oeoQ_(C`nvqQۋh | 8uf:2Ce5Uѷ۵ B{h[ ЮIR_eDulq9k|AVgV/)KJ,.&$nE Ԣ;{UG{Z@Ǖ{8bshhjKiiM5eVꗲoX'ڔ't+wŽޗxo[to(?u.I+։++I,nYŹ.Ŷ]6gmϔT6oY2}t\o[߿ ?D':q;\e⑾So.ԯkYfil609.ޜ6~K~tdZn^{Fdoعo{-{F/ɨۣ?|޻Aye>_OrtG&sVvѶ;/:Υw}ޣ'fp"yquOFE3ֽb{a ;^"{q>9FbslnQ-(| 6qKdnA 2qm<44⩇\w5>%J{0/?]qwet(e/o^lp(2_|fc+N[hD=o1^) +W:zPy]g @@܀wH&ӎg4׃n'3Tz$FtH=3v!6o~Fr~y-cڶo?~~c8;fVC,6Jv K^ɺ&OJhu.aӳZ.}TvG]4p.9+I۽m|q{Ze8ebtC M:➢x)iUo IT ]n!.BM+e)LSN01"Yvn5]D634WJr@PC]ވ[6oHxsoO7}Wo_׺b?[o_ݛakZ0jJ$wpcm@5h )UX TSPV_^o_|>;>o܎I~~j7w/x|_޾}}oی~>?߱r뗟ynP/Ł㍗!$X˘*g x20颃0 ~F:LFjWj7'K\ +P͕!AaE=QN+PoS=t!V̫nU,Gajt8_S|,Pi\kq чmf" +A~![%^@F9[`l`ȄElbb-?#: Z@am8M2Rd ZѭalZ><'Ma,Ct|~@Iqo2q݈91>L{Z o4YK =b4 +46)"]RE~m&O%wR8^y#Kl +^8 +6x_ v'b`Oc:8bp]:f6 hl|NKR"rR=z13#zCB4k`'!"z w8v@SZ25!aH^0L' i>a;|,럫t+/;X x:6 F91kE "\gQ~Hp盷;c}`A@=:9"P "i#@v@P+1h 1]%v1᝱O供lM ˊuCl+LRqڥj^VXk_% Ϗj*#@@e|0Y J`1C" ֢`26.a+ 6qZ=h Nmr >&C$0nU Y;-DL<؇(¼x'ԃ,B`C!xvJ* Q2gx 2W-) E H1"< +f^.XR89.`Q,qc謅Z380࢔x'Q/'a|3i?So6Z0TщFυ A8s1B]&yUla%άt緸QXT1B6* (Jf a%QVb[#+U2=&c@yD8Lz$݅6chE2* wlrJ^&!Bc7M[:t._Dr${Vlwy-qsܞXgЭlLLaH5W0Pn -K o9R"7m{,;#UM +;Vz v,$ 8=PCC{\?HE:pGH \=U`+'ij3Ni@U^9݁S1d%H y;Ʈ߽g(#O:>NmsT(K8>|L T{ߪP+pWvS 2Q +ʀC‰.dMзy&5.a0uWkU +E5e/ڡV;zS}dn+#")qli-8Fu.(2<#CwQȿ-Jm:N`Yb+fNz(xYN$ZV%ٰ)%;TR-TC:$Tϱzt"3i2*9ڨf^JpʉןO&f/**(+uU#=^= RUHĊFX#\D?'tjBA{r^$# +pv^'#DVyx;-H 9&)&G+'HH (5^SYIa%O+KHrn`2 +x*xCl31/![2:2TzV۔̸ӷ(ɨz+0ߠe J,@DUrnlYcIc%;>8\5I e":W?8.sM4 ΋VPLTߐ1A"m1JUZTCoH/ˬT!_G&N* CB>Fߚ(D=pt` +lzNlӺ\qQ!ZzJ<<ԐTbȊ*ϭ7\x:%՗ȈA04+(>JJ;NR# +#`s>͟3Ӆ/MIFKW,I* 4IcԐA!"CnJNBX\o]*3XB 0!G'io0U4P}ki\YP L*5[*/uh43=Uƀ<6$>f4MNj @<_M7}nҭxE>8/X1! dP Ɲ(;ukMB6p^y:3D6P7Xd$?Sfո݆M,ܲw0 J"5=O TL`d-91qAXq}+O@@]YivsoFPFӁ[&q\H``p5 %=˘3 +W|(=Jǀ84ew*t:k$%$$6qS՟wF\dtrSˠ+ę!%ɉA*a<:$P3ӷHi@&Y&RPA"@LNYY*d(KC`qz%-Bܐ!Ig:ø8bMa*w Lt`t &{kQ2Qihv,޾/AJjw r^›"F? E 4 l"FyhؿC 6GLxЎh#5QB6_5H@Y}5^kYA`xM}\Ԭ;um)%M5FaV4V8pcm&bjf0 X+DӾr=נҊ +Rh5cBPN0&f|sl͌ +5Z *u`kYHFiIZ?챙X)m3u'ÊTQ %Pwr;^}+P^>yg 4*rÏЍDDxY< \4e6.c=%:{W q)wS8F0ε3 (8$HOlTl?o=5$K!N0eTfzԠ;[ Re|Tmը)9_6 Ш >lš@f)ġ8_^v A?d}UwP'E)ޓk8N_dQAK_m!pU`A X^u5PSb ?Ld(% "pr 2F_ŋ H +'7Ɉa+Pʫ/<P4a oWqȆM)!8~KZ#Q lav[&rvV /MsV`)ކy1 fV8È݅yVfZhPZf$g#3~#xQ3䫑B] +P 0H=mE0g?$KMit ʞ8KU+ VKJ'%%Od~E (ZHE⓾' X> O29_9/ %†eAՀFBy aU=L"@\5NMj]4#Fnd[?6eb#V65ʗy`hmX h%-&|%q|"4TB}JNcVfϜdo?"c +khjh2tfS%dΜlk1}N:a~R*]rk1Cc +'cpZ J)+D+u ZX"(F:dE0ҌD20 -Z<91r~H˦7;[$*')*"قW)Iw DbQ72;c/S"6װ"9lO@m;ݵzATZ%WVMOaoV4: +TA*hb(K*nغUѕH` [y_"I.,-`3l 8鯢ךTΕ[T]P Gs}?9fʔ A ?8Ж6Q2 dԍ!lQbN$d'ՈX2?Ɉng!6DlT~XȢ%Ī8"> 31^ZHU 82넽I+"6:EM t]+y ++IDĒW\ tW4 m`-lm/ǰV+a岧a#.DZb6BCVpDDiD ugZ0K.hqksd&HUqnSI(H|ænj1&Toa FGՓn[%aCSahq ۊ0LaZDN68g;VGJUy0 Q#m8T}hkAg cala0*:Cjet)j66sZiool HOX`")OÖV5 +3v7W3N+co] 7е4:ؒ GSI?xA洂X0B0%xc)ηE:YI5)7`VxD e&icMgcbP"om6 Qъa5Ia^QiP"H\hU#L8ҧNzy߂B>)j clZB2-Cx![G Ij jzN]" +N?dRL Ob;-nyɪ@Lh skԽEĄteݜiUdNW( n bwۆL:=Ү=Q9ٟ,ِPtuK*2Zh)GTj<4Qq72n Sdݦ2TogP +60[}0hs`"qҸAi7c-lk:jv&=NQitag /5k.+0b&2K nCj; TO"oI AځO5P@$ɞ^#Â3--HAJ*D-4;m.&kEcN 5@=9`݇죙T:aCV1H*J(j/#Ob;jAhTe:g3Ђ("jݣQ `rEӮ";"!MD^Lfˍ-]umTTnz jIQHjޜ@+DH+Uhn>Ӏ&,M q22H9=8 bGC䓨 +Q_Dх̨?FW!i8p$xSR+s10 2F2~Ædq fK'_tix#,o-hc Upd `Fp;MǾs:{DF4x +-BY‘ `!؂fIUWfq%MޣujJ9J{'%4wZc XcۡEpy2ьiˡ4Ž 2W vP!,6TJ#2&LGh}0I,u ٨a2Ҡ VL,]t;B ݔU4D}av"_a9 8QS$T" "h(c! tV(R%J#sU>=%%?9Qh@#|f+Eo1}%MA'z.skcSjS]d5=FM;j&fQm08Ln !3!GdA?-d-cM#l(sr {*=Q$=0t6u}& 3h*w:ʒТD|`h!ɛK2rkV4ѵ`8`h^fK0rBJqG +F}!h1 E CI_^/wo߿`M 3(QgPWyBa *6xc)'a|:ژBC8g5ՃWKu4HO(3w?JKH䂄VH2jq9;$%~dm2!$AI }G @X#sUИb=(搧L߁KK2@4KಈƉn:/ K1KOVЖg BxS-NOޤX2@=5[gt) :vdf, 4Ї.!S:h:Á|bL[J&RNY +g^Jge]*Q@+E֊kV`@d 7]%lцSeSj!tMjәY8E\71tS)DnTB;Ξ$r7* g? uT#lIЎ aMU2Ko<_; +?|¯SE)xc4Ng]Lj.G1_j%KxvE7w ?[XYmopN"I?hk!) +۠GgFwi32S2*xEa+ LJQR;"-睜SӁQ(T!0aҙF =bU!>I>z)L-(d t?5*nSSm6*Ȃ*`YԬfe(zw!#-=5EFP; /soE.;ɲaIT_j$E t/1!=&XY`*A#̛R5AMU!fDCVjga1!=[s;C% +|!p+K *XjRPHq] _CMXg- VJ2PiU(lyE7BgVܭzEG~  -v,؃FPTttJe,Cێ `[_% gr-)jJy:%a$֩a +N-hj +C7e+6Қܛ?d3(d rJ!83{jAFJ&[$J rdЫmNJ$J@FOIuNbTp&BșAqT%W*\Y'AT"N bRrQ-snl$ҞdQXجQ$aW*rt.R`r׻r5=j5 ѼZY &1N-A2MUNQF g,8=R=rĊF:YI>Jl̬?/o2aGȽ %J#TWII*%Q-^.LMlšȬLS]Z-W!P){e455ӜQ +B='h sكsC,1A\L-YHZD]/&nfbC{nPbfDiy&%4Hܠ2'Q +;/) ؉R(7ҦTW}c`^$x-5e3d$$y縃d1$ؔ77:cV. 16@Uq7tDi$33-)Ƞ,'cX2)K&. +5oEp3ʐ7 Gʤ³+dŐ'g1,*-} MU|,LI' asѲ2*Q<$;WN̡{HyLjA+pnT묅v"}߃*3'gcȃ앋JPssFdxjA8#d-*HT2:1)q4vQIwJbmbF[F^j6+)cb]Ih*9 &1cf>JrTPa0<)֋,2$k1_0VT.{C%M Lj +1)B(.wV,p=^=^1V$ơ&u(;mΥ(}ptZ4KFP1_UXFc[ \ZsÀQ+Ƌf|V2IQ(ѓ-^oTdTS2Iͮ,9v}31`Æ'FFu`r[,2*v z&+=2K|F}%uٍ hql (cW@.:bЁ"ITC-4_cS@}B+ +!>'.,¶!ht񅦘᫦5LJD*4q.Qpq8Uup4<'-/r>g25+1j>x!ނ-3[2]!~hr==b Rօ@܃P瑌G{EB' GvNFFZ6ߞzhB6j2_AG/!l\hHJG?&yxs;(T`VpO-N8 r#ems6Jz5xbwrenx7So *7֘Wˎt"!&TBnv=kNwRfކs'؛~ܜҿdN li  7/]~75 &]Ⱥ˜rr4\`!G,56Jo$^~oT`,˲MϜƇ P9dF9Q9 Q2݄LMfYD>*`vwPn|6ρ +#G:1uRt w((>?%L wʫՔ# 7J ާ"&DoI$<3x+/YCoQkT֪t%(xċ(J|qc +xϢiE񦘽0 귗ՉȐ)>B,R<97f? %U%H l iIl.Ud!]pQ*(ژBPao$qQStlkUoiC3 +kZ(6)k:'%~ཫYՖւ]^ R)X)ipEau,okM[RWM1n0IcCt7ơං`,n1~+b +w_8) +4^u Rbpdj09U&')5|3<g=>YP*搟H-`f"sBP%XX#;Ӆ]CZT%dnb-pEc1rWT%CP7ɀg>pݜyC tQ,O9+=MUrE egb-ADt`T}_Y@ylgPcal߁V%G:=H\z Z)A˂-\QdZ q*%>Q ԫB-\| ܯݡ%6*c|ь3 +o!@5+&?WR2^iO9 4G$QNJ!&jr65UE>r^CP!e?LZ-,y$8) liIc%zhyp/_+vRס =ySa] +S 5g#hnldczlcp}< OeZ`9Y[=ɛd>z!JWqw*ONOi><:Dt61Ba*eH_O X+>_ +e9 =FژNlE5;T4GvTD N>PQJ }$ F "f:x.qָTyc;Nd&Lu^ͥ5Cv`K3{|I*ADedia[۰qrtuq,wEH;[yIL!Gm,}"Z٧<;Z&BLy~{~ytCk33.f;1XϠO^ߡl&u0\onSzI<$ "7SlFĆkڔOoTGPcXtϯ6C r{ yE7D2:/8vhjJ>筢\6.Tn(=>Uh@3!۷M)7c ו8ARtU]nɴȡ#-Fq>ӯz(>1 :SR(GyOD^V;ZؾBq ++;מ9 +|Vx5F: +k>¬ब }!^nH9x%ߴu&c~Ra??0>؈U&$/BD7Q m2OS`l" 9o#AnΕoU`)J|Q= YepK 9df^ 7b~`|i,$Ԯphඞ7]aV6ĤYMF%-! :w ˿jpu%Sv+huntE xt_oVjG=q+1Ւ* 0STɹ9w 1l!bcE Q}@"O-5ªsi$Y>OmB-V0S:YAy]N)] :Kq7k07zX$CD!#||OC䰸NGAj*-˿oChGv+D)-Fb])w1w@dd&=?2v}@A->X'+GPzD1R΂D `"IIH}Dpc3Eʘ[(= D +"i!>7Pu2 4;OwNJW-tP&+i@-m';o/>Ȟ9ԀuzwU$3.DzF(X6Tu5t1WȳѾL!5daHA q+8RoC#99eG0y6oF*J<%ףf*[ɡmsFz24Z3F fբ^a#ot2b9@cZ2/2C)qu!I hǹ@s*PH<"]Y;JxwWIBD7pKS,9O;mi-0{h[]U踓cW BYv$@ÒSEu\enE 9&f|áe{*7+֋۩+I!Ռرp/~SN<MԿjV܋6ؑ +kKY!m{ wLjK -M+0q?R~O?o?~~?8?O_]󺂿@W;SݘAs}7Py0ۭZT'U6 >_ڛb;`ouA"'8eI7 @moQpўTy5ҠwȎk|xO60xM¦PKJ(? Ԏ}nU"Aa4u@ގ}o +8n&Eq̽׻_@sd?S#sOgGؿ("z(r ?sDmu8?]*=F!0UҐ;N=2њ@q"ԛ”QH}! E']!"ݬN'q^Hs040{O!PXA*`}ߊN}h1d9SMSv%GuelJ 4=t>pDd*=g +7Iѽ^8YvP*rg%{y"*wa鍂^6+Hbs8}NM"}гNubD8IN0N?=GJ;E^U64Ha%Mo =룇ckO'fv!HR`2yp5`8BRہj:/ ?wSn@Q}B}2+ +[~a$ՠ0N! F; 4HFj~+] zt~eQC:l1 B 2Rt#ۖy2ȜdKv7r?PTߋJl9$Ы̊3ib@ a}8,hLN J>B ?h藯@#,z{ JqH&}@F|,Cb<n? T;{(cGe?B\( +7{:" .b9>t2_pâ|-Zo ;>Fq^4 +!}.)qj 'tM>IH\w|B: 8􈷶&/=Q 6X(ЬU6[<=[bjV=hH0%݋'O oo5PcLN;+e,J22pQ%C0(7d2[ma7PRq0Q%Fpz[ +6+jo0I9'kQr#E? #pCbG;"MɁ%!6"OH"H]B+IQn a,,UG|eZՓK W2[ UɅ}l8aDD̗J{X;%)2vk;eލ a006C[ +08MorwA_ DϤ=! {y0bNՑB& Cwt*@Ły-pej|h`*^~qǪ%ёqUj V-'\`cנ—!Lcս%^͚joRAQ'˧Nl#%D]p+7|oUo½eI0X9&7vR|:U3">0v=̕%Tʅ@ȼBBI|ߊde1D.?OO"Af)tR~CIG_xIUJ\ +nȱA\Xv!%&%%ۭBl^=.V9Ҵ{xcK]]AiW!b&UY`@k 3q /=R +qeA^*J&pfoNGxUv8>HƇ>p;~lSmЖ㛡Q~;]mM) +oދ9 ˷*P}n\$I1:tp,esppf~|-Q3JJIU)nҲG+*v9 ,| Rn˷~Jɭ$@1:Z-S1b4p&fcP -PnjzGMI[?~ĥ ޣj8GeC +u:V{ 5xU2}2!{VN&R +oW/7nW ^9hxk !ؐ\ߋr-9+m9G=M`Y|;T{(L$rMsglȮb X =֘uxJ4Gh={Od$q-1{҆+mm2TAMТ 8i2iV赁M⹅ PD$_.sҽ3^v>& +ogʣ;Z(1 +?r=*8 d/?& +M͉aaK`QC*= Z_s+&Y {ᜂh z8j%dg\V0^am%+bC Bt|_yz< ɽ 81P$*ӽ!o}kxr2#h;8L Y+'QI_)ld``U8dD9,>0'tk9fXGŷ4Ed!@=ʄ*HOT ,HA_AuiqXwG~y 6~~#Äd: 52VR(zޡ&wi{r˶ 5(c\nv@u{"F +!U=Lδ{ĀkOeCGOpε3L!3!8p&ًd(5D CM*(L.d` eV3?yH{IZ϶ADm_8bLP2 +5~"6>'-EA^" 0}M!|:JscƉYӱ(MKv$J}{M`y:Jd{) rs@S DɛkN@d)G ?wVD}\s +Cbs/簣JDxJ +Ĝ khG2@F +f!\V_%U;lDun0V)OC"v4)IS +ɡ> z +MQ' oG}CBW:DF""S B!<`bzq>5ΐP } +pOƞJUv + j,G7!13#-񝜗aHGh#ďm@lЇGD@aQz{`m +\,(ph5 /(E|"ZX$s +UdN`Y-v8q{w~H tuec6 CQ+gG߶PO1rJSe**]'nzy9ӫN M + Baj@Cz+֐U3g{=䐬s>_uƇ`cNHvі cmb:ivN iy^G)\9 (RIDB2'tvMgߨz]P20HE}) z%w/ᬉi۪[o<,d XD-'pWіԁ kB89}ciɌAAgZŶSQLR2?"?Qu-X=d=HF"gdFiUb͊I]N#szU 3Vs`:0DЧ8=,\1=L3 zN,$[/ +xCeCbԑb /5q|y=o P`c8k M҈R[=N2P a`+FھfcȬШǷivʞ=vRv# {pcִjy 쇃 Q! 0:Ck\g;5Y#nN*Sh U~E=B?Ӹgq +ryDe0_Rv C }Ok,#; +#–iߥրb6N z1}ħ|h+H{Q`#M6h6]UOٯuVW? h#Q~vz`7{Y$.(iP(Q4T_ , TljlqEN"bWɤP&pFeLxW}C7"b9"XY^+K*-a!v@;r5HAK{{VzLK =zexqCP&Go/e@͐rcszlf]pMWme[=bc?zwy:n8;rTǓ,D8P zx&΄k)µg]zejǶWGADR8K{AeҝhJOCIS熅 +&xu\H$#ٞCAzP>Ҍp^$/aet/X|=!N4C"lj0;,s<*•*אY+ =#k&+0ESfiT xą(P Irr*ZV4KՂPAЇ /qhkyBN +|B:iҊRcͤ/]Dlsѳʺ&hcǥ#nO=XYPTo:=ն!~T(_*B @ػ-= %Roуu$h3)V~Q"]ϯYOK=_'o3{3IP D$X%p-Pe +y9kU8(7HT^qzPTf{00!dܺ'LLfG A˪tu8UTB"\ems4y)m 1|w Es1@Ьb'Dd{eTbqz=zp'/0*pWP,(\5.7*m4ng9q͠UYqD9=JgXLQ濽` @A, +̎ӡ>;þ$mѺ_ n94V("xXU D+/ 0\vty"p gN M缛D$XҠ4HA`¿M!)MD7U0k3C c҈%xa|]Fy;YՍF IUߩɬh_Y뎥S]n΀V};%%H +cO$\hSm c a4jOXD҅vC$(Kg h[8$sxlpA :‚z06PAWH#fkC0 {V=]ZD>Bb-ob8Bϋ 16(PNƦ>dzT-{;;o~jJO΍QX> uU.D쭺!%8 #A N)c*VwC+UأBmoǰ7X V+pUqXk{w4͈aN" 1g/KTGvdZdزp0FwjAr  $ʎTR&Y ɂ3ەI|Hxf, +by@>FՊR2=3$3#`g"ǙT%(S6O'tzAI/pBڽJ[l='Uz瓴0 +[}5y)) BafkCW# X+rLݛ7M/HD ^-ao ɝNul#{A_ +Մmh$)I`ͮ@Yt ut@Csz(Q7^f)p@ZPԕn)܁ QP U!gkxDA +A@/Av=TK1%{)HĪu^M83 +ƌdV!MBE4V?+Yŀ*+[l\O Q[XQՒ@kAB$pe$ SA )^I<$T+Sز2񀢖nUɣV,]<;GT9aЖ;n0JO`1g !~ıl7cAɤ$9E2҇L922F!e\Kn6=RLȡySrŀ[-FP2#kR{Aw9kATLmdӤ2C9rtJ]x O[ξ`r0s|qay 9| +dkFs@ HBpݜ##fr&}񲷩<{o2.yC [Q +$D]KB$YB0^^!@2@a#ƈ6άAF2z,9Gz>h3$(x4N 8oȖ֊Np@F*N9 +$dkAAۨA\ŷ~Pcg-R|-jj/{]Rh-0 U,6$]%-ۤdگo?i6v䲐DԘ&NuWBA + UMSwiR(#l7PObثY +7pIunY-~UI˔~*/X3 yp-+(ZtrxHz^H3ДMs ^QDp`d$u0Ml(ԕ=徲< +,R6Vuj~ jJĢצ9}]w3יD~iX”gAZV[`_6sl D +!ZXyIRQ2ܽҧ_Wȿu;²'!z8}U Ή٬0ba{ˡ?w >өDܒR( y:1a@@}MO ba8fc誽6p DRv$9:B }//)⠋ދ:Alp@^ +tԩiz~+K޸aad s]Aa=Dp~'ksL ryqYy,`JBP.iwi{JO&k5Q::#+s@ +ሪ-#_- %,zk EJ l|pSFPH>9S5!QkA"ҟ!c]$(OI]M83! +"ek?ŰvYp$٥Z&CxM +;tf?Z!LM8Z-q~  <_0]|(#$|u훝{qv,fWaz|݆P)J= S[2,SE7jyPdr%C'P@,Ao(Uy*BB}\) +S+F= 6 MpCaW}9 +2s]n `.vws剭MR#EіIXы?`9aWrx {i X8[12x]տG,+̡d 3.W&_cyOm! ϒNhid}H=tvmYuIGݟ6* ytmL 5yKTt^Ǘm >:i974~rܰKHS 510&uO\pǵ%#Yajb1[s mˢ!PИiTmSXwA= 6?n4xM M(@i"? +?ΤS`iff<|"|\Z(EGKa2AL^o(A>5R }{qT~,Ҁ_ h8qx6[j)YZAc$*LUC9{&V /Q-fTLN })q kG_m9r -*+w 򎂐GZC0^|AN~+Ň1F8 +bK_`=E2!Ld)\xh ?"a2+{|1<BNqܕCW=ypm(ϐRIcY3Y&0~_fc-X?dBF +H.|O'#UI44W癗mN +(8ʺGҲDN7ˣ(=~9W)i J8VuCE&ľte#FFT]{OқۨXXsጓ]zĄ Ud{ +CWQ*ߓ$JN{ %,‰HH_I"D`GT6e=zudwB +eٟ\rrpbk#o9*5W؛SalW)|գ-5s/X 䲹Mnv 34vጣ#b?zov(eKk4J %{A0T# ū0"[fCYu]6u +ڠ?B+RkCb!+ȯw= [ AFkXg  +'|" \y0EÅv".#s4sqbth_BLIc!:O[nu؉P(>Dצ +@@!@Bɕf*GɵJy~J <ꧨs5k`QeSCtH(p/pVX;N&P$m1[' $/HM|M&73@%2 .;˵P[i2莅^ '1ˣ̩k(У2<,:Ծ 9Zjva{QuG-<|Zsj=,=z^+:ʔa}Fv{Ho(7|5'h :R0}#+P/q-+Jސxԃ&H@4c#3`B 3+ay~ +%%˩ x6LUq*`֑f=%Q%#L/C*2tXjZ8=0xS.f"Od}%`r[^ VC3iYr֣p)KE NlE'>Ntz\~ 6dҊ +):PX[F^+/:#hϫA2bDUna5wLu{_FaqzQ|8Ck9#Cp" +!N}PV窜i|]P'TkH5 8(2 BT⥶?Ȇ"O{=tmTVOTCU vy(܏V X"*eb?b=39js^s^#hWlwPѿ?<-z!塖*A +[ #wGvD ܁`Rc8ȟ]ߢ _T_fJ'2HMk.dQ wşWHvL +/9[VAP;;ij׌Ba@<횽R#D:{a>%X{ĽjZЮ-/su*_K?= QKL Yؒ}-5 +nb6=#BMCA<g=<`@>ijN'пqލ`>F"ʶC1 +,(pӢrҙP^=B:#Y75BF;ZXnuQN>^i Ks?DRdog-@m$^['Ҕ0] *g%iZ RԗWz3zi˜+=T{X:W(J&{"Jvtz(<a5SΉ"ĥdjS#x6 -z(ڴL91يIkĭ t],{ws:j>Rbi ٸN,Q/-0[;Z!g̽#CiDY-dT+vo-yTUę&CEۓ^Iws4Huӊ(PdgdV)2 _K${_na RJ~>γ.nhg^WamE/ͷ^{QEb5E3?,9,Yp9 0p/?Xְ M- n_EČZY҅yKLc .`#┩wˌBR "5/ӄE AeP +`%* &|6S\vjyyblA:=f=n;z{c{9D$q+jH=M2H xT*Z׉ԓP Lj iƏ}%\!6ɡU -H"c[d1b"M[*= Tp8[PM0&EM8 O3#G{\Nڻ/?WB/{B V# 2Z.{cr$'qB5br a:)*(1CSkaeҒ!Blo=IdNzaNSh/۫hZ8Za `2aھç9`{Y$nu{8DT_s,8( +4AGLRH4 -R!ȿhlBH6JԆ`oEo?X؝SjVn+!'~)׋ލd#{[M)&@0#h2~q<.)7;+kyI` >IL;˺hm_g ˩ұ! Ɠlo@T鐠aECu?`dOnW -a0^uNf0[p xAynNţ&.O}ao ך@8C0z<1-!>Wa \X!PTN5ߵLgMޱb&f l,#dw rQi^IIWua_)<{˹“t=w[d*s8[7o_ǙGyRLp'yS#TL^'@ Wy^-x6lD[y\= PLy TgEWSNnjui;Z-@FAi~O~pll\BaR(.H<#fO+QNLjd:ߚp/ -blZ-JRTzI[z9TU xnB+A/5@;WP;x#*!6y ŏ-(|b4#[g%ҟ$c/!'ֹ[gUUZ>*&@GQ% +wJ D| m!q^Ě3s8$TF34P];ҡ>P=L"Sʒq&-jjgxng92g-$Ҡ 0L}("Ɗ6a1[Ƥ~SIv0$} AnT[xB"BuCX%f,+c^I?F|ߚGkg$$Qsc A)i@W4 ŷL5 Z 'eBCφ)rz"2X aF*T;![ '4!BfkNh߬{~N/ύ+[념M}=$4,&y+8STI>Tdp 31fX`Ln qc4.R{AП0#{=BR#h?].;,CHNdlU-= }&W#mÏB30DN|jl>9|8?KLo64Nt}"hX ;xfh6AEsYi Nnjj{0qTr߄fR#Qel-KBoHܾ$,r!-PT"f(~-H!L뙸*O!{TGMtʽ(y'6HUa CjƲ$2 ƎGŸooo?O?{^WH_ #'K-@ +K D|3/ՠ~37m?)j-bNj-(BL|`DGDXt<\&꟰' +d(g FpM̽7Y ՘ j2VD4Il )nr2rf~,͏ vnd8xkoh{\;AX jYdBsfar8jҊVt+=7OM vD (x #u$Ր%-2 躷g~8h`֛옱ZV,,\5_apVw*򐣈oH+X͓̲Is*Z [Y|Rd)2( +3#$W \pAf؀95wWhǼq*ńg7gNQw\zx|< =fXs-KRpgW-8L9FHZYLJJTŕF@둀;Ko=tANRY\p)_y ;@e"  c Aӆ?\0fyԞt +^ gP*n8ZU~sf;ӻԌ86k{ᄹވSeL_#_K!M]#l31؉)4Ԍ KSPsn8ϦMtĹ+W{NlΛTv߿H`8;֧\aTIQ髖s @#ż+_+{,a a@'cOpڌ ϒV27h ]/<3Ĥz]tҌE8(G%W1@uRxā1㲲]V?AK:&ޭg[X!z3ZJGAؒQ5z88uI;*ŵ)!QxԐޅ:k{ QϿ_;Xu# +ԌY\5'+*~A® =M₊6mrSi++3P]vhQIhfZ"'W +<^Sa m\1,ŷL%}3vo8Rw00|.0{eA.M@! +A +yR $u8uWmyH 6E,߿Q ,'Lx[fj C|;1{i:ǔg8--zz#6oEIsA!NCIÅ/P7)I$?H;e__ZDᶣ#݃wgxKK]yoۡjogBoq? cDM-%_aVJl {KmD & #od(9 &D 0s̻QcC8WpiAD9zNDZaNFRFgr $l0U AfER,?;@VPS5&6L(^yIX'#JCuD H,ِ4ix^<}k:.Iɦ ی zc9)IU'#trĎ&:az9 AsΨ2h٨#o 침S6v^/YGW#,إY^!s}|9ڈ{H15)x>5"tzìCTOc#0Et#46 P}h&v O sҟݕ1.h9]G#)X;!i| e]_I,8 h`DC[gz5/47"*h +<5ynܹ |e2QfOd*QlO5`~x5Z{3zܐOdދNȵK4Vh^:+,)@#M|4zzz>B}}45>F?9JI jϗ7C׺ސø2i4@EWZ ]:hAZ^'Jp^MO^¼gOᗰ_6aKӌ weTV6~ɿ[/[꫽BUzkTΛvhHƈhD +A )_VgԷd3)=/@\AȜ\%z$Ozoks?7;&?^MY&&T==0@h3$?磑K_w;opݶ@A*='~o;CDgdOT7ZUrs=x!{F9F Zo0L~ǡ4w JTLHyIlD~ +:R]\%㐆/Bk똾?9pHg`8ظ]^)!ƒZ,"Ьp$3tP,yGUx(wCI<\Xk`p$jGQeqdNmH 4lk. N`ڐZ3u 9㫺F:z) Yìג'"u)&er|`ע@~Ep*!$) C 1CNj]q1 ԉj4VS= n~&KI>}HX({B^dA`8@.s3q T`-\6Z)> O 0BaGȀW?K+w}?[ Ԋ̂pzs5$>Ԁ_tsKK~Uvnq4~5 hTňE{wC8<m,rE 4M/zad8~5uiXmHᘹ m62:*EyD w9=ݯYԬ 5CX|($8]H娾T_ԛiw@޼:R3Mc&Zڀn_3n5bW{5},1S}"=ϳz>"~ qv.k:3CLؖ/h(;HPAKSKuT.m7@Y@8e!m`b&G +}{ OE \W{q:>9LFio^#wGCn/]Ǔ0v$T PrY7^L!S8SP;>Cg*4!0lvX x da ΐͬ'UOX +%Ķ{U;Y75ŋBV 7\gׯ݁=ܟkCkGfxV/3:ۿ/'HQ(VkϽn>0SU*j<]Dr㨺\UT)fk Œ*oǁ )֏=~+Ƽy5>^z\Lj?^{ùd\vlXݩ?;sd9<z@Tmwgz"c]ss#;*b1LFq)8=Gdq7q&YZ `kJzxzs_[S^JPR/`^}n'ħ u4h..[:]Dl!C[S@)6@69܅80nl{>נb(a2ȸѵ8[{۷""6È?4<vfW#:2``'jsWF}yQ#~ʼnBKt%^wٕ@pHЌC)(A&RЀ*}o}}g +Nq6N'sc78]y2*u 2vC:(@JўjSԮs{c,YI_t{@ף8k%"D.kh#&n"Kk8tc?} >]լ2΂uJM0TdG\Mh'诃w/=PUߩ$pcy&cQ3|iîo"޷4ޣz " }t7`d՟ }ʌ +ؽ\Ŭ:Ժy6>Ul +E'Ċ{~ +'C .ҟk;R6 ֑c)].6CqM%VlWt"hlސN&=-#.8~~MVvX9YP?#!y;8BV9Ǥ_z$_7@U h臷mV}KW@b'FnX`Gv%Em%> +;)Ϭ8+<EPьEIJU7]<rlNC7cOH^q٠|ϫn+`( @no0(̄g-N\QEO1E jlgޮFtњE7E:ڈ=4[Ts9j$@㡎_@+'Ab31 +7 Ny0/opB$sz. u@=4YkR1QTW] +=Vo^эXO= hO hdDջ7\;Ohw\%PQJ{wϧ#wJ!5?(eHSXwQ#_pd&C2~ 7轗a:Ze)YD3gq)ɵg{E@$WNd+ӏ- +qJɭaoAD:/i@ q,i/ rsBB:n^2= N?\řBR8r|1IrLqFNKMK+BȞ0ܤ@4nE/;=Mu> 4W,ǃTLizpp#K:֯9[àbu(fe#uZ8VvPōT +i,8U*yyMY/āed鮛#b^̵$`&^'UO ԟ&h/s#L for@/{1! hB"ؙ/bX֞[ٗ)1Zy\QaBkvշ[ӷOد t +%Tn%.,:8K r ײ$qai)uSѾ ͍s3z4=D/ex@|怜Xx2e˱*.(.;8բ1vߟ3pyWұc T[ew} қY) a bLM,mn>%6;ElAE9q>u3Y . 4݅>CJƢ]@u +BJ}w(|Zwٝ-iePI2O b՗۟ymsQW(N7zLGQNni=H ]gT9<J4 38ξ!PC-X Q kY?7[(5׳Mԁm!PHw*Mɶ U`,t@zG!կעT +:x8NNvJ]Wxz@o$1.]㗧sDKRy?k(Zn;(B+ezv548m=b·RR||H&*~7`54uYAum:JDoNXG.{!mG +GmPY'ՎcwCƨ|nӱmoVΛuC0 =S%W) `d^qozvFF0wG +u{ M- C %TeFnI+ +ĻBǗJ&FטXPcʒp~VW| 3Ďs2T7W*!\or@OG}"ӮJrڢ vU~5G: }*P ձTE~/ ZR>3:y!i_^8o6 ;CU]o6TLjQEye zj Oj5kO>d==`Sl7[I&8'$< G_8KYEU먧A\_lebVpY?)26o;<ptѰ&ח +俯x\Ηu›"N8U{㘋H2n~L{}ߡ(hk>!}j'dGQ1]/owd@ 1bIz]fnt\nV*QeѱY֗-%?.:j(hIлb &bw@~73ߤO1 6Og%=Clĭ5>=^_b(4@c3{Yӌ8 1.;ͮ^wv>;/!2PW U =JpﻜX#"r0|jjD5}&gے0B;%N8Y6?|| 4Sš@ (*SgXN{>i5έ>%qlF\H!1L¯(9ۏJd TIEIT*OIS+)GFo"e;\B7u6u!~odW}YtGY%6҆#@-<(;<5r!+' ӆYv*4VIcưL{ +j@0,FNU~_,-R- Tv|=;qu1{iT+| m׌kT{=e>y[rc%IaBt +yY\$W2ilfe OZ-2^G, ֤Qս^y/H"iIvO13uꯋmK @h]n/gN TS9mEлcsVMX˲ccatYIcR:шC\b]Lp?m}aᛮHG7W;z~=8N ^M J#D*LD1Z*<(6鑼ED,&T#5 +"PU2}k*a^lvEϲl* ivAz: @3NJ:*lt/?t|qiV'ʕތ1t;J$UWMKеbp=Hvd Mİn]=b 5zT9$&C"}Tg=`C~O}wV4$G}Vd xV:Y6@U>+JG߸6kRA'dK^>±pU'sv: v?BY͂ŬrmmLV H&5nC,6Ǿ6/v YQ~`qB\iղ_tZY&p8C_B i" 1zWNi:VmdFCɹaDGq Ħ[7)rJY2bBx/I rPܱ<]S#m|4 T! ;FP Fz.1_$o_da;ɴCq8G Qa8BD㬨Ғ:8MɿhLL Je#6o^ALOۍb ԍTt-Ȗ+- pk\Jmp^uStD4arpfu5:2cWejjL&wFƒJT^k}^Gԣf?0Nt0=P׼lN̓upero2`|jwOA.!IFu [fqoGG1s' =GtJPgb&xFL#n5nPl]CSnfdJ|A6 x{H52Q@@Gv=}ΰ/"u;%B";pdk'KE8Ka,<$/xH T5-[u8CopèGÊgZamu6Vfԝ~3B2y& Wuʰv +دȰRfʷwh!@kiL>)H+"[sx: +>3: gX\W'0GW27x+9^Q)b1x@5}{隓OV:wJ +tҵjfΕP+bBRAq"QOU!Rjdger3Kh 0kzSާ;ȅUz5jYJ~w+jH;%ޘ İqGaRKU:V +nA8h8]j축 .g"E.88Vizڮcl ȽmPcO6Jth~`8+7}|hT\b^w۪`./375*Vk~ʶ~-aV~_2"30agQbۿ"FW.\EC)p`҆xGZ B1296y>cNC;`##_{ww|T}gi"¹$z=,E49zęrE9 l3c?~l; +{ غ@1&)WzսEʾsaIhlWxgrڒN=L`wTzH fLyH3p9 VWCm#9<(٦gC[֢sDj'h?#+͡hsI9Gcֱ<>x{쌬`pP@e}X]AOj-iL+is=ZDE@(|l<;3=kNF|eqnS"$d=1U9R|)׊o}g^Ce*=*";hV~d! O޿ *f"%^#=qrpZ/#"Bv?bc!7tp@]C[ӀVTW`R4;([d-dNd>_M64|MLeY<# Ҿhۙh"c NA25A[}== hW2fX4ἣ^ gQaǰoU\I~ǰ"8~t9XD/!)@ͺk()\k(G]"7= +-sk=+F΀fTbZ yše7 *ƖV,iFx2uc+@yvNGVa˾cKzze؃~Jv;*Kƒ|'cI}ѧw բ%( C!ӕM0:W4tA,W endstream endobj 106 0 obj <>stream +>S&$m6iD+>,`5(1_M=;ZhC܂Os|;F,ӲAɌHɔu%,)ey=GNF[oXB ݹ̞UKԂq5><ׁ-̓cC\Jt A!yAa--"9c-C UlQ^2B݅v|- )vAP沏toǞ}M3\Wվ77M$Mk20$ێ,}`3[FLgFآVVF4|x?D-{t||tl]S\/ w3J .CbȖM#S7)1 zQM+ғR4mhBR/:ej̚V.%lNNJ>&.֣(_Qz/B?W҆,PXoEK7U>7.fZ7*rP:m]oU ~Z:5~GJz~WHtViI%260u>n_LgOSIx2G7`?h'fU@So}DS 4/rOMwKgD^4/eZ,/|3-'o\ZOH6=E0_0~WpLFt=)FXS#r1>1t<)5XX㬴 + :賞گ/<2JFXnԍ_e画߇h,y5AG]fJ] + 4= #|7K$'}lO3ix+ &rj^|ܯg~@5O༃'fbT-Zp[4t +NgcUNoѺdG8|p>܂9~=y)zyQ6׿Q^?]~*k7v?#Ivc-Sw1%16N3 \wo?~ =_c XHȓ  +;;fAr_/Er֗42~D(I/V:a8{|,|џUP RF +E{}D<lcf:`Gh +kS;vR )Edo-j ++lq{L[뿻B"qL)Ԩޅ&n7̴;oj: 0r3 ;-,zE㷇Wo߇)dQ1Jd~cwo%A||nҝ$~g[{[9e1LT!PxݻWNjαNw\_u>@lszGUS!7@ d+ pLc*J||9S.~`yIH ;-Gg ?>݉qNp\@7>]`/DD7^J ; @~shW68~ c>~)gvsah [Mg-]&.\ +DndhC 0g_wG .a1ϤzꃊFNU)mIP +89=}be>*=Q+oU"}A$ߢ܉3\)& YΓ6}̵?btFш !f~8DÕ;ן,YER| u\[= +l:]ώ@s`TgD[9ORnn>;ٟ%mdy(=׺ +`#;~Ejh +^ː6ϰH!0 z|xU q`<NG5Qg,RC7PU}:+܍׭r +BloJo_vuTG%URѿdEg?/v_ >_?| ~Br֓,^`Uƭ釵=Ae.Z. ̠em\_'~]TIJ4דe +Ug,N>EDCVnЈDc_w}Q-RLl<@)5 Ҭ2 + q<6U&wkec ]VD7i< NsӈrF^ {^krʡi-,~+ŭiV2dj(lDk#WY_{g]uV +# aJq7]CZZplz/M9N>q4QgEzʂ4}vFu+A9z1Z{(@+|^(I`IEPh +ׇ'udG ={=6\w6ǩ=ko[/'O=Lh"Q)jWVRr +:µ~ +b|w>Oy"pfˣB;a 7an BA\+8vkWvE698osψbz\8՜f}/7ufBgQvF xよA!l|#L1{qQox: nKu.*u;0}u{}yAP=}/^py_QÙx>P]"k9b\&nAaq<«Iݭ +M*d JEgwOGh=}|S$8-c@Z.4+bX{ƶG=Үd`UH꿷q@|3C5y!613FfU60%9l.žߐVa +k8XEO#qn: lus*@a~!ldH™xĴ*A8$$S]~M%glz=g 깍$6: HFFI(6IFMrӰ'9}1L 蟯G6De(# +ǓxuB,OPVY~tXKjĄĨ'¦劀Tc(g"o#b {F_ +1"-Lq b%\DX6 \@\zJ2YJT=Ι5EÐetᤌ#E]lf.o +0<;vXyLlyӹ2*R 5\vbwYs5*yWj>6c2>[$]o w11&ݪ=Аݯn%fOHA}^eFLbFxS;.zʅHs%)ǹInu%1c~w߃_kmBG j eG54έ4tF'ǶzoY萏*n}uA=pҔdNL7M/F- Ga݊lI~r}iZ/g F[ϣ@FP{ڀ$P{*u5Y^ޮߜG $oihsk1c5x8B:OOɅ޷L +@^Ǣtym;C^PR- +vAj^ht&sQX֩WJW8@NW<]@1Ki f5b=ʾAgk*ׅ*x()r G9Ic.݉ =K)r[Ψd$;"j o"zt'/'ϕ>(pKj/P1IzbK0BH7UJ9O>ʮOA'}FI~ހM>B<Ȟ~B d䔆:|| r=`;ldvqkqn+nևγؒX0\u:9[Er7?RGg=kCY8fU ipv(cX!#2IEi1J?P~Txe:: X;u<V/c%ۨu+bp텯LGDI7ЦZ&jl,wLϳjy&D\['RXF#o_L#~rG0c-o6?~?szJP  S3f"誏QaeԕFbr (4V}掁 Z*זuޏ +1 Xxt8a- t/\W +eCQvo$b!.yy.Pӊ8bW)V RDZӽhWrq +:Hq(:i-T8^"WՎ8`W:xl6j^@ͼR D+χs#-VFp.0-qByt'4=:hj:;tJcnJ <"f*@6bE9Rk=uW!E` *-wD(8^?omRu +GP b`eu|SPd}2Ug4ߜ]Y7:YO` #!*{[94+]BUIS1R?"fT-+|B2i.1y&¯g<- OoΚ(+j]?#Vu^(LLu +oam_xQbZѵ@RY[9l \3">GBGJxGctƬ + l9p!6Y0-0`&yX8vX<:(^]L :άl˘]O5BX=btF +loOnro)jx u0ڌLiDk׼๒#Rfy$JU֊ +o<DjЖdJ&δI1;95հ`%5l!07){8;/,Iِ'W: ^=gyBPZ#۰gMfQK{" <Sq +fE:G7;(MȰ"Quce>l, +n3vGҎzU 'gF8H#3*0iR|xQ + ` :B&dV$g{X8SՓ"rUg#X[ߜji]Mfd}gdܧzvŁ{ +P'ăq_QCR4&6p{KYm,f# ~|rf9Jic%WZP;?؁|(k"ڻhw@|Hw~WfbOc྄(2շ5ґNUYi +PvM]rngyvgT-Qd(+ +yRCMDIC F;zz+r-^__txf\>|)sCbGnM`[45KIm%?Vif8BMrh)CV~D+UWjKbЊI*$^0_UE2I-`…z}Ni7Rs(;.MuW=?d/@ۯ_XbGğb]>h*Ml IY0 }">r [Ri@8%{Dm1AC`&{6Od@iC9;j z 2"W%7B|xvO7Q_r+M|sb\u8t{M#K8|lgA-0݁}#;i+ +0ZFdm-}ƿ,4ʈ\%}m1i(76;w$ u0fik=O*Vs-!P4|ٛ|>Tݨ@ϵL;2o&`$q <h%ؔz\`!n9@'4W=oTC9ӱc7G{FKO0nڬF֦zxýu [;FZ0 1Yg\q\TO_붬*¤83z'euHD,3 +<:6JZ9"zj3#[%VY_}@ViŊ-%  "}t*4?#fTA<̦(kyXU~nZ u[x%ݤy%AU>.t9YCtpt!MVKA|E\'-T10Z/ZG+PzuE ϕcZIm?jYNÖr]@ؚUh=XWu\7g +Z(WNN2J9Fu;KDY JDbJwzU up|̊`AgQ^ 9\Asz!OtGyڝ9?`_'N;DҴɆ3wJGE`M@~X7`yq + +e?HKm9$^ +462| +\•+FJYtM/WUy+A SRluƀ_)!~83 +2E Y35To˕zu֭ +ѼL 8Iq' } 8ʉ2v +~S9`~Za#5@6^XmfZ ̯N܆Ucf9Er ++ Taf`LߊDMa9,.:Cd;Kp|DbvSGPUs%``&] 9)gTلs6 k(!RVFu&Pl*P!#6}i+id [TM uo7!mҍi%,LH -9/'F"԰w56eoeVmMJ@V]kqnJ8_\$q=ڝ(WU ]¼"9تI]JәB:3V]+u%X+[%*<#Xiw~y)%B/B[gA[Hv?`3F0Uifk)MK68Gr 6:!~1/RزkC +dmi V -{T$Z:H3rweAщC@•C y16ZA n0s??`vX1$|HאY\2ybritʅjly|+b =JO:%H ڶ NiCГ? "8JkݺZOYPlE` k;apʅॻ(t;5rc~ hOQ0gwz#9-ceT]2<So|οYUHRBPue2iΒ QPAICk"l:DA5XG==Dϋ tD.3WR(U"{ DhG@[-$qJO׹r.އV CJj +-ߗ^.=mO S׊ڵ ?y=Q_XrR{vWd`xhv0UTzpfky%‘P}*Ʈ^ßѾpvW/EFAzM~7=8u[wyT** + z _uoXf뀷 cV@-oMgDIV"Dv.zɷ/aV # +0;hPJ~fGzcyPdhl@Q3T v15v>\æa/W}3d_<to7l|@tDw4XFh2wdu~\C`6߉xRjZ}]I釲 /P.-$yR+kb0e~XW>zvٳ (t-,U#[?l *FÅ._@Kr=בX!߹ᖀ"!ޔꯍ<= ɽ +mruT#b+! +r-q!by"KA|l_z01ЏFy_T,)F+Q{&|B+:G fn08np%%WiQj:{nJ`~oIʨ#kUE{U76 1`ZRgϘsP; bBy<g̒q2yr*^DV>thK̰[I/BTAyR{t!EM KJpv|+*$R(i vgUz%ÁVt/!#tq#j`vg];j]d 8"~_y@}(U%J`sRSr,0JDotIE>!'rm@ڱ$/g/6^I1Uz70HvRz}d$|6fj@Mvн +:ʈ40E9A=2BEԡqρTJՒn&{<*m`2)|ԝgx3qyL5Uge L7,RTՇ4 6x#sv^<^rCu{%~:#sk֞{  9+ :yX1H]6s`3)C" Ж9u2-6d=CwX=-n{OiOJ0焝gVˀB@JB#v igvMTݸeITtaR +86MnGx7:I}%CB3ϷS@G2FT-Po Gȏ + rФ98=sDfM6;9)-E)nW%L}%eѳA7z,/m-['#"`';y(ya]==x#Y1`Rky֋ЉAӏ3'~8;iu*ڧ#F O_KEW|珬|֊q6ѵ+Xݹ;}^[h[B+cx5e+. u&񒟼Q]3N'Ξ-^OdQ6x4L/ȸTrr^0#Wp}#=S#؈1Up啰{h-Nb>UCtQ~-cqlP]4=zEon4a.$]=~[:Qre 3=ބqypcӐ#*JRܷpDmH+;؝Q"KB6s_&jFM[XǙ^8P*:.QVS&/@g Uڻе&ӶVC_{;@HC Ǵ/S:eCԶRaU?HZCnm~1ud_gOuzj|*Y]M*!<٫2Hd[M:_"t#<bz9vqcɧӑS6r) I +ޡZh*n(BÍ:`!RHMLoj3!! q _)Irgs"Cayl? :#&zqzAy|xvy /:-JQ)H3r)q +9hy|{Qy)[; +yrcU67 +E V]Ӷh%w~e; + n*oP6=.f\U^&M .#0C[s+rL}ÃJF_`֭Mϕ-2$}壂,Xm|=zܧM< g4 Jx?lnkh ̨d1fgs]2<]\{2oZ!E#kQ>?mV Zf>$Q;j˸3<qLӣSJIOGu?EF.?f(ZDh6ӂ鴼)KGz:3xqd&= 7Nt2׉~ɬKFyTԎ-Z ī,)j^ӛ*`x`C~ןe|^Gao3i۔v@\86n 7h!3B^˦:3cxf5kh&:<']Fx0 zhil BQ>p~bqnyV`^)= tZDRFy]qe8Y grmRJvޕ.0!;?SdHOy-<&T+ &;;1 k0 FYD׺0_vPءv,9""\? -*ZMqb[QVq4ij)yU"hgq_~uh`IZm#V\DP¼/DB ?bȌQ"ꣾtzUxzR#S&ΜBm\m|T2Sm3JT%fQGh梨C#&M|'<Qfc HʠlE(i1$M? +r en/#XTwrJ$1M} +fLǙPVZӛWdp ͑wL8dW ]CK'>gYΨTso6I_oЇ~*X00@9K_5{ß# j? MUG?h fGx9pѬ|ٟf8?\La]3z(' ⿞,VyLm@<&ƹ\(MQWTqntha=ˮYaX4=M=j0Dʿ&` =]kI9O%SS&$+oh-Z6UsqV4nA4bv;09xؾ&Y܈WXO9WTԧ7KLqPKO96@B> ݢ%c:%tߛmv!_t?~98ikr͇9j(t"zXV <~J!V#C{EHOp@Z1! (\vf֫wR"*@+3xTIscLꂽmK~cl^D[xf@Ʊ zt4tAxT{cT{:\({.R)VziX`xMEu^dCd0B[>p?FvdY0tQc'`!d3Ⱥ+x2)H#=mp1oс^o*ԡז>w7R$b +2VM1D,jl3/P%^/D2@K*Zh2k{c1<%%szu){+Sv4z7PmMW +I(HQi#7Y*\AF2vS(t+ TmTT({Wg%{_0FD ]n +6^X ?W>qhυp=#J?5#Ή8r\\HkoA,slPxP$*ˋ%ǵSozaW{2VA?ܔK=+` +##f3\#%7sX{Y?]=_W;+N#qN+  2Z Uy?Πy ף4$\[QFK΂|P Bvņσp^M?\ +Ȃ-JI f.v[`z" 7Rx~YOEIq٥XL1YTclECXuGVh7[U͠Aq48y _Bjz +V$r5fE@`1b_x}e28W:SO W" :QCF4DYDyy-a8|׌l PiPF-|Ew5Wlj2⠌@sò -@73^L?[S:?mOb% Ir؝bY3+f0'aD%׽->SP!fV@_1=7ؙ0)Tp# +agd13X7iG +!9 +my1f+&C p܍Ztɴ2f뭡Sa:tgo7CgAjjqg=S^br$ Bz)Ϙ^Ir%nPצL^5Ed*9uBw;U'i3kD#)Tbs2:yl"b{>1Hd. X%c0^)! '+.~0" q*C{2x IwgxH \+_D菓EMNީqP#$I};J]apAG;wR˭4pU4b3֞pfˎJz%6{@WjPTm?4=5]u}p2 +%T=Xr:LFB +Um<60deZ\v2zڅ/2o|iѱ-{Esc-)Z3<>ci\~TJZ]`U ^&3e_Ni\9bC綔*$ޔ})hR,HG0ɢڎ0 zũI@w+&/P?zdB1Pox~[PPD< ݁Ү|kؖ"Hl=B9 B6[Ⱥ@:++":Nڹ] D"MӮ?͉almFGz->gf&\6Uw,q_^`;xIFߜF) zycHH@|qrk{vF@~6iۤـ *<S2uM +(i'?EطTQx7 +†ߩϻЏb+ #:`6:ζA7#\vo^HA8J=_{r2h2EnPv) yBF*[~P% RՁfѫ-O+~#6i;l8>.W +Kw۰ 𧸠Bzdq"@O)іϣ1E#Ҷ&o_Z۪"huveHag<\T"^6w|՚reXU V%Jy$[R0LJ(Z%G0;PO]#~~7 +f݋m_N`ujf :BHY^7@.6|IqtZpQ<:cTv}bqW>F0*JE>ͤq11i9W&Zd=Wh>~no~ՋlиĄܧxIQH:8:KFHژWThpn%`vQ(|0­`l_]Yl:L>ND1WxMQ)(d4@ebhXaObVд)qiw)HvPN'"B ]U 3'1+h-*??wLQ$mSJkZ_Z䇊k]C|Q遵sB7?m",n$SqrKV )/AmXjڕqBőJZ*K`!/4=l-B@*+DevS)c +k8j^܀(Oz٘"@EGKJ+_#"ѝQ)\$>p f(g8зF,$wq_FgcBx_O{4kk.G4fT9uzUX#l mK5J2b=t OX?mEJ]V2ĈLvT{AYj]F6`UhIBSd|ݦnЭRmɜU+XoЛ#&LPB#2skmEe +?<ޒ3iYa@" QVREeI2(+-\aB7SC*6a:  PZ ,H"mKK iQe 6m3B?%ƏS5)ߑUTM~uC_u^)P>l@o0b0'fn ߼% +K& kO}c}GĦoGChn:D MIxzF:| Bgv7i̾TT6C}}FEˈ厅26w^7ghuUp=j (\kQ̭GecpÞ`oGs$^qɀ +;<2YثUF\Nu}%]GBG({SpAgsüvQ"iuzWa$ŔA6Ϊu+ 0,y=}MDI49=p,SFt(-7nD'"4%@H1|Ԙl/¦F9 u + -ӇDq%ܯY1AiF NoBvh2wphZqi'n$)4 `6+q|"HInM=u`!dֈͳ@nIqC yx;XREV}Ѥ?X0)tkw f'8Ybz`49?Bt1ʓP܃1"% n 74o"g)5yNS+E~4 Rֽx΍£[C6=Řʚgq']HIDWTAAƕH"#‡UEʍD j"ҽsOgd_yOqh#;D'% :zn:ψ->eL>͘;LYH%nT"cp+`7%(]114GU)B +QMM`g4cr+:iψ/q6Ad gVI+=|I9cyhdl|b-Zmy#Zst7U+}F|I-xnp>6'ӜL"Qxjk4p*b8C ( WA&SNLv?*1jaf4"RcGեzp jui;eEN Fܯ-ć{>SD`j^"[BDה1S8*Ơ ڶZ@݂jBi8.bK7f"B9ƒ^}E}O`i9DȼN6ɢ;⪯C\#wHfoQJ40V&뢸2aHjIQ9ߣ:PGMDx8PB"1;lNR8_*ɶ$ ٹjSz8qJ#)AMHt)FN}rIU"@/r~: Pw5הRJ˓I@-tQϼg&kc;⠍U3$hEЧ2,pޢTQ!li4߭w>4kj ҵs?)zSgѳw]9z9>K{V eMSp(Br!D=j-pnUm[E'^>?};-LFG~w陴,ci.E ==R<?Qo'W';A+`CN_31砶,9X@,}=,P _b.:#(-C$BҌ5lۆpQ-j\\r+s⩚k +k3]*ڰRw~ x([Da&cyǺsGQ掘@Y;dvT>G =1Y IJB݀8] Zm Jos:pR^AN͖o7 ,gY㬍\v{g?֘CCinZ9lfLޘυCi +>8)櫬.VDMD*jQek{?ʠkw(Wc:Hkc? +]Ga>FpFAW+E%>FϞ/ +RybU5:4P!͞GRmmOY6UC/(ux!@N#:: `yX#ӯ1j&J; ށ~nH㤆I-xKL~x XBbcZj({;ӞZz%BX+t? _xqkwK@MD>AMٌ7;|n@rvƋ-F?Gk~ 0)o?QL|0{4@EhGxoQI0KlÉ2` [ߗpd#e%*e[TT:#y]BBRua]R,x kGے,ۆϽ|/vE+FT\r,5ݤM#ozݥcVvA(~@cںzmL +*aBt}}ou0]@lIRP2qni#mV1Zv#U`:3\9qݹH?0pX6J6tgfvQewY2\~&wZ̐խ5r5GdNiqb=[>B(/7P$/Yv9#-MN-Po|9 <F P5Zv.pv2-+s>iQQEiB~dCbk3O" 5an+ һ WMYy`s +לò?v#$gF`3>VDK}ꥣtr?UF8]WÝ:nen[? uGd~!Vur?-h!5Nǩ/*bC줐`Y[0e~֛EY +zSKSzHe-c)4E%tCHY+-+$bl;+|x^VP}Ya?}k$ : j}Qq|:"7:p $a6G7ν:sJL/ 6k(c)#??;׾[HE@cقT0BC˨-6tוm,"{}d?pm,8CJێ[v؏"{NbL龜-ۯ73h]Z,sTiGE3 + $)Z9_T8C#${3B%, `uL~xɋdj{wϕ&Qh>[ow˲X8c`F2^RؕSEW"8{_&(Ϣjiu"q),r~-#2rD&uG;4%Qħ#0.?K+MWlˣQp8 `a0ba0ЭzuQdVKc4ؚL%D`ISs7F!#ϯ\ +&|fu}zxƦEJo>=EOu[N.Β~H5U#@:,?"nnTSR^UC 2׿+n@y)V=A{շ0-y|/G3X [V,HE>h Aq9Z1+6 <7$+BSz1@^e@$%D9{2$_cKb7_oV~?ǿ߭P_???N~o rŔ9  +zx k?)8@nbB+j@ \_-m-.c86{ hPnlM +灂ݠclaE]}y[dHwم +R2kwzȹ; T_AkRHE=enNCH<#_%BUTe@Mhcy3bmkCz|=8!_heѻ#n}kZYWaЛOkW 524]:YoD9#8jSpN.ۂV[RUl6J$dDCWR[=@8k(<zg;z(B޹}^1QSJUhyoo&M:Chl:6ŘVNT\+U"nd&xmڹϤ[Y(d^1F( (Q4P&C/b8KJ0x21qY.`ۥ K@ӷtxzmAhEi."r O܀ WHQ౯y @,<m7wu&Ra^SEd9 +(bȯ +`Yo.G <384>?^"5TEIRu{ + mr:+|gCt9X\kV&N9u2 ӕWv7QRWZ= #%1F$7&El>G%u^L=(oٝ ۷(jc\rZX+Ldą@8!D-i#?]}5.ScD _XY*ѹ ?{BIYkn,:ר28G~͠Ow`w(AdVGQ6&DZNCPWY">U*1ېfE`赏D@&)W5.b tFQПgM!րW)>K~N\7N/}[%ciLX ;s9,2p<8!_!*'X=y2o"ۅy&Ux+K> +~&B:X`m}sAI=d(`=+s{FCU[slǻ{Ŋ x# R.-rۻwSР*q_H am+ث ?Eɠ U֞`S2 4oRޞa'n[x0GRnwP+,ѻj* ˺@Q[ Εa.M-_uVFC1 0ʗC-4ScߕDK;xc1DlQV v<@Aw?QP +}ӏ!DL3!Eo~ +?OGP't/N!; +>3d [.T 1MR% +։QjO176$>wyFX]$9ҵ;2eXȜZɯ]ڕ6 Ե_la^YAv[l(%N0)mrǪĪD9Xy 9(P :=݋_t~6$J +T҆Ix `lQ +I՞(hYĬM%'լ.S +#wD9֟`'z̓EGO;]]49 XU[zJY)¾$ٷ- +[F}N-(>{n?WR#EՍx1Y<3A)&|~1SGRLSk%հQxJTGzy჏v:)d  `[CCnb'EׯuUufFUĆ}W=WvGG]9 շ_ږȩf{r6a(Yn(eD' =zJp룥}7M +M$W"t<[fa&ھC۬vSCW-67%7x=򻌳Ԟ5`ATՂ:sӄy&A3Ж gx^]&V?vn9̊e挽5">g&~Jp|9[ZS扙6o'M3Xa][cl~F+?fjk +2CF'^48yF`Ǻ·=slSh{gN\Lfc{^z84aYqis>R 0RVH%KnΪv~3p{=f0lsΜВn{b3v7M5*$J?G=Z`J$44AEa0"b+9i*eKJ"+:9\x&S(E".%ͧn[@Q07Co{E/{jӌOGYl}A@h9'1Uv +Zw=j3Ē}w A:`?F3ԮpfgVb¬-4B/Nxxr|9/6ڂHu<fU%xʠߣmy[7|!i8Ҟcz;u))+ +~Yol윌K x*QI{t:~n Q0>"ďh *<\ ƶAM3A֪$)3Si[={Xɽ{|J'0Ǯe2Ě]u$8ZdT@ĹrmC"T9˷_c S8|w\p(Pln_L Ϥakwt}]?Ӗ$*4$ + S_x?jS\j2rW<:.w3bjJ$xwʀl`͆,Xy$%7JPWe?p +9 0+0ˊx)[8΃xHk1y#xJt2#@QX}oQ%F;3/|H9G[K":, 2guLl_bp27<-WCP J vu c+OyꢼzW6b,GUQ[L:07t$KGiGGUkK^"Sħ-Hk!٭3P-Vk;qC ጶ7OA),1J߂iE hfٍ) b](jhNvx`jX䚅֏Zoށ~|mdܜEfYtE? +5OU.b#I}lzg} ls GwqHJ=v6F` +>'ί6s٭J!:JFNLGbJEMr-\4Iئ= +8.Un"Xj(aE[{Pk +'&Ɗ5uJ| vҦϙꮚ41t@tI9ŧ;*ϑ=&y&|wE5B6_"&h]htlG;< i[V鹧 @nl*\#yv=YzSN87nz.wUhn +$I@2 +|c?uIIۣ_[EA,u5&%fRrwKJju LgdIfk ?D6b] uM+[a.ecg YyL5nniŀ^\0WcMY-v"ju }Th&+ +PZ Owg]W4v#Wy0s![9Έ1ЈQ=pxykŊTL }gyԹ8XqvzG~#@~eNx+Ѐ%޶sR@T\8"l+u1ךeg!P`mmMƁQU-(u|8Ē"<R?D"%B*2"WhӚj׭;ȏKD$Av?1ziͯa +`ᝈQLȹ7W+#=(۞ڕzrZGaଈ9SVvOeQ_1Y4 B%ԷUY58NI?0,"/h÷m%X^4vzBSQ?|~.3O  x,P_\fL:K5)ڼN1YnVK\Msۓa<\6<첐*D(H}92⦕"x3#nQXP'̴Oc+tթ˯ʘ tAϗ} H=`1DDu]Ƀޫr`, o +_z Rڗ@Es5Y+pWH="(rQ%)Ec[ E/lu9=+m|5MlgI,nL8OY'\)?CdůXPWt>\i Y7(sF&)-ujnlZUem':_y| kH) /R{$)A*ū:wWf)%^ ]s`bKi# -DX{]hp@>*apEvkgCDK *+z +֏NftzQB,WWظX#4-g{Wjq /إ#OfR)M^ 5\}rdxm0T Q3$uB·iHTu8)<OZ+mE&^թ_E#*HrJ˿en@/ . +b3NYresL+jȌ{{6oALJ']7CqpH[w)zd-#9o}Tv-.zO#1]}ZIZÚ}8c>~ @xFK L^0nxchϩuȫTDk?w7 R'YRayã5d"IhH_O?UFL`8( qPiCz 9%A̚#]FJpW8=۞N?:ͬe uW8x("oȠLu=-Z:D< kFTibJ~ E- Gh@PxY3+H0OP_ZۯaPZk^I؝Tr| Toa}] ({4h3/+2dqYAP8Yw[6/[Mqи:8YE~x@y |u#r=bq"rvfiT֖ a@{س7#=Y\r-< :(ʞ\PצYƝ\5E^ Ϸup,a2ֺK?$Ya^'7ˈ)n2G#bwn8TV;!$eoɣZiEnV:GžA7[iF,QeC CSC5㹞6[%£l5`=.D- Uzc I}ieCFtyBV`ki/~2Fn"1*M5*f=5vV/@,0:h7f[҇{Tyg8YX=S2#5#EŅO\[g^ng}j +z+O@5piAD7Hl `c0dVUHt=K5P8gV@ ݲ~;д ;ձc +rk,gBhC3V0ZCmQ#zG\th>r\ Ts> +Dauxй@z{Dxr^1\V%` 3hRQp|DZ^QXj+,<톪xW<@U4c#LS^0؍IZ acJHTwhb4kX8_Yl ؓ}{BsMŞ6]*4p\7zk|K w~fpMgL U Zx7e Flk77uYXxxMjGpՁ^߃8+,C.M^GRbG`Mef'ֹo't-h2GdƵ?#YY=Uh)x~[hDMpAgKDPW@$9$"QܾտU1/ +rvϭ 0ZN\BVYvWIWȭ,;|v1s.aڽK3弯,L)yy·D*Rۻbu'AAO2+eu@:MfRعHEa=?)z\]m^PruokV֠W!JT#R~#|Stɯ0c]*H$,Q$ϙ#a0dHӊ jxeI[PEazoh 0k%搬@Kk`۬##z _"PJa3Hdܚ<ϢcT |cUfܝ xX湟RJ%NVk[ss<4׮HU4P",,C>󥪽yڴ]@&qҎ ]`+xJ2Et$խֈV~Xp7ga: 2k/Lb w%_!Wb)ժevl9=h0Z|#8]A ,n]<A4uo톈zU[x)ZPi^wCm$&3W@vhXDD!Jef="£~J(v sz &!00@/G:qh̸0_(.S5fek,k5>R{0l߅#tZ2^k?! [gDt` M3u*U`Ftm%h mR{LS[vCo_7n;DUǶ8s_F''+P ި3zl-9[L5~S{AantΕvFjU6:\%61Pwdh]C"r%PN'OMIϼʖ:"0wNey!)QB"GpzAp7FH%QNu6cXR"vE_s,N&ܚ k³-f̓^pB耋,, "vƗwJmuN?U3U{^++̩9[jo1RZKpsS6ck}k P_ (($puڲH!UW].6ƥs.<Ȃ1{=q̼du4TZ̑X{?eK&-{H=3""hneީXÛe>vFI6&FQ*}O؂ZF͍8Mc:shaԦB, 3zt*7s b": bvH4E{d=\֨^eį,Ưߘ&M(TZcMV괊mj$YtTG~=>(P|}`ݵ5Г ~ZX*tғ)@9gy>00$0"x5тP>n85iBj¾RDMv@֣B~gPQY܉3RJ] +V0xE.$W%UIO@Hb[M`ڭDx=m{=la;U =A-Ju@H\k3|ҏ,PKf !mW\|nՌvUJemg;.ʁ@!4Q"7+CH9"*l;5+U5PUmW&gOyiǦ^&[8(1kMp2kQ ܘ s TC}[Сް h} eU84OS-6bX/*I4#?E$!U. +%Cz辒ͬ(Y2H9n#%NSj-l܍ lZ}(=**T8}! 8\L[js㱪r5PV9l#]gN u$JԲ`bG|`\uͰ޵׸(S%__'@:CHQذ&|;[{z` jjipKunl6,t}ҥ.ћ*@bT\^C+$J t$iFѮ-.n6qvWش`CAL5gu#8r490qkSD}g.$a5$44yJSOۂwvtpI&ӞWQQ.L 7L4z9e2fҢkQp"DD#HT +SX[;tVJƐJKN +Nķ OL>n}?Ǿ5HЭ>Td~81|+KaI ҃oZ&M?T=i0'A8LtVnvҬd03K44_=L@{bs)I($MYTJC5jA54;CVFVH~ޅ(4VP_F_\eJQw^n(yEzKo[EZ_kE!Ag.!U@9镀.E`LX e*Tr_9Y=a-Yw"= ̥Kw)BVBxxVK:Ұ1'u})Pa}P_9,8E\>E"X}|UKԑb +R<|X#}&[j`3-A;\WYk˦ΨeN~Nc 4_'1qaΩb[A؝., ~#tutbCHz6DEFف^?RGJgؚ|bs^P,^#.'r]#E$&j@Ee3Bm( ]\/"PbvØ'4n! +hamS oʅoCl6 y3ȫ2 k7wבR"AN1Q![.1Ɍh,YyWi|ypazTzM$ȩڵ䊣ͥl'.04(O0A}Ƣ>o@P@䖑 +Fe`aoYEo&FE4nHP/ÅrL^&Ɉ +>0p [ёhίSNv'P3"#̣SK/i^ +XO4~6ZĀDߥ!=&l1+ؕB]ɗ DYo'䗽%4DG9D%;bg+ט6NAVSޞ٣k@/4a>Pûs Z :dP5g#h5Ȅjhq֒Lz]f ߥ=$f E =J[r xQڋ|33~̾E<y$+B*V~Ӣ~|-Q4:,)z rY L5˽#Xk}wj$o)`ai"TQ7{Aj]_:q>hhfX[U5ҷPkD&ܲ1a`6F^Q KR@@rΐ1if%&8ӄ = T93%Z*hm2njGwRճS- u$м;CCSz[/1iDf5n^x[ Ucxpy=*(EzI/ Iqd^ȯo?yCFI"}=8Rb]9UǕPٸ2_jw.d;o 0uJRV=Q] }8w_9; _O)@46c ~e$>Dt0}eO9 nȢ wKƃhK'u0yV{2(% 5+z"лzʅkx`DZDJ5Jda7d>Wd>gtGJv`}s:4& +3ȵ5 =]t>*䁿l$)ʶv@z1$x#Op2pYO39GRHѡTbФҡ/+&/Y\QأD+$6(*T2A7pv3 eEvY~>εRVL +-QĴ$Ns+BUf".mRﭿ|, :D5W^w3A"p(r3md]v;. +B?&=9aSiM]xuRd|oP*giD@ Ĉb- 3}gR>R0sea߮:&֯  *7}mptoX[?EH2rS+6+j~>h{\rEt 4َrQo:@>Ӏ2Au!}v"xQ%H(F\$Opy7sŝS6WO\f@@ۭ.g4߷y]W"M> jUQr|"ڎؤ^sD UuC҈Cf[T@8I0XU"V۸7 +E-;L)gPeET@~"s Rqn -@q5c!bxN7X-GsT%ٻ diL1k `Y{[ܠRF5eRv[惮vP?"vUy\WDE4b-Gb?5-o)O="ȅFeK}4\LŠv~7 1ze<_L>%۬ƿ/(:=)s ؐZ(pڀ +ŕʺ1H Tsmz![utIf]/~bQe,fm!̩Z!KX2K[[:^ˑ48ՃYQjwuG!Ơ+Rü#@0,xtr I 灬_S +2ED=-#m/{W ݞƆsA*pXX*% YkjhH"]QךjW)'Kdڏ+*بQOD*Lm)" k4iܣLq"YKoRywS2wq.ڷR]g%zO<([hT?VPn6$^OR^RLt5B_8 :kK('JD0eE>,mЅ2XCʴ 2|-/0T|+ArnY #)v "ԓn_}?Qrn_v[ɎRϪfzfMEtNYv-jOS5̑"G>"d)UvhWѴ?=\V@O)VPمh7h܅`S >HZP4$ԏBs "g +Ŝ8\TlS+k( ܷVo% y6nlc& A3sEn%Cj/b1)`[ƭR"{mmCf7;5lefj񛘍~4QIh4)f*sUE?%tLN;gZ:Wrn\؊`EJZ顟fy/V>:`gPG\RrBs; +ܑS ` +[-zpbSfVfǘ- +&*ZL3`Ia5Xc)7I+;p40VR ymoK!DK=0i3U0_cLO)PSTN(j:GpAq!-xolx d]R*|$u'@/[YQzĢ)x%痛]~HfR赙bR\S2Ҩc:`ꪮ b9;N:t5+^>-Q"[f~wJu{1bż7Cj%*ZWL:(޿ ADPCk$ *uw\!TY:$7dnvKa1MUVD"qp:LtVL.3 3{1;s.X($QvWNO7ѭ=̴Ǖ)e>z/v̔q}mZ^m`b4QNy=!GꖒBa$WC=|3l]+b6w<ɢE#  KtN2( D.zZΌ6[`:toϛ4uYcԤm7Zˠ4@ۮ=F/ϤQ;񘬍 Czۢ=>r~8J^~ (_t{6ɱm-񮒞ZMD }JD^ =8Y:h q}TCH[?-'a?ֱ?Q#r˲k22>OY@.NXkN>EԠwEņR©<<لM9ki•֛/Q\{@ŀ20'b-JRR}仝է-0Ԣ^W EُJ6|[HeP%eg1+qdKO#raYlDCC<~hT*S|e@=H?-ӅIa?{Q}}Y[Ǚ{S~ZvDHCaB]t NjTdE4?^GZb57N dcR$֎RSaPܡe$#vk(kۆTN~ϛZe+$3T9J^Yrf;1lj; =ȆӶSe#N ?$x ZG*@($#$iD4)K($"׮Kb(tEP$J5 Ftd#iaQ,ލ4y#J#1Sgg8VF?%izrze~ ӛ+fK Ίڌ[hEg7dk7G`ȁ _ȱW}a@Y|rHOhN֟"?#8mSʔTc=G5 T\h\5ALF--6WbrV,=2PLZ$F\gʺ5S:W}Q.XZ6U.rM^u"qfɽȭߔS^ZpEx'/=g;lGyfGgxD{NҺ뽋-$xþ68^1>DTC +ĊaL%zɝ +Vv^N66&a[]; % +(1Q~ ˉj3Qt +ᣈmFZ@ynC}ǮjluAD%wdRC9D4* +B<-"ꕂ\~-sT"RfV=|E3\9 ml{2)4i:#q}46]|&|Ca/CX( )ɲcGdR9Ut~TjSTa +%(&L& ?MtGoYx)SF1`v_ɕƩ,B$G}_Zq׳GBV41QTo`H6(WAblMۄjx:{dDiiI~:"Ba=r +:-1*2?d>÷ 0j}#jmAC.zf_NΈ>/O7az[? %U.j >|n+UvG@{έ̛ZMs {K: zV#$lFZ}dAdA R%YL|MAlyߘu6Z/,X!@N$5U]sBc_Փh{K-<|VtguDsB4sSAV& J#\,/ڭ`)fl!"4f AF|!HJ3sE b +RAJ + /xԑ_jYxd +~T am{JAB![8>hr E:{aIEB0y閷- YV|G0;*c䎍${7;Y4;kmR1(}L'K۲/G z~(DQ$r_cN;3IlGnR862^o(BBӊf'M:囜#z蔃fYwĝ%OI9GQI%V}U+j4o`¶77ZWQT~%02:lf2%F4Q?~Uɫ/߬9ſ?>Xſ?/_???z?y`A&V:j(@Z5J 9-)MQ0*QZlC$'075$Cߨb[aw&+Cg8'18=Eot (5xmCS<|q$-(ؓX$CixEY?qIU|}Q둧҃ÀN pBЪ ұTv6;?K=$3gի~jv2JEzb[OiH/L/Ԋb[[b.L +S4Q ?Qw?cI+۶C{߉#•JXHK&eze=#6YP@bho9ōÕ4W|-: + ܎_N +НU;@\ߵnJ~ jI3ֺVn 2@'!ҴkRP؝ro< qhrYH#E\25Vm[)naQl̟#D(|8mXN_*EƮ=fp:ak&qg1 :+~4=ЇNAGߎ´N%VL5TsdULagrO{'[Qz F(O-$iZDP# [0-s9Jq箹1xa%p4{ SfG&/ ֐˔9e.FV`97{}*],i{rh,ڨpyӶ_8Ll/13顡A@/|]Wкq#&$;%󜎜wH&>X>SD.Etɺ[~?zhwxc=F~~>Rzn{]';PL Ǧ' !*d%j't2e=. 4Hn\7m~A}? vRyq1 jX+O=ߎ/"/fH z-B }`+X_̂İw4R{h-(nm_ odm%D%{Fȅ`e⯍0vpڏ[Q [pfj+6z֬vcjPR1!_V[dٟZ2aQЯ@c +Q掶1B.u|NW`֌2x$HTy D.ݺN$򯨓v092AװoVI^+TYkyϓyO :NT4X'RJ[7_U *݆1_T|O&{r1tCeވ CEuM:-@vKx5 W q)>gts@HSIjhN45OvN=r~c\©k,m{.:n WзiҼ~C4ɊeoQ<یx0^+:?D|ǔ>/ׯa.C;>$ߩGSߥ U9Z[{xE( %{Y}m`;} ]@ek .s} L86W7ȅSF\T~.nАz yBOz-\X`c` P34A@ S~WDFqŶn,+̈́u>Q7eH-2#[ޗE%Gq3f(4IeXn_-^epJ^_)|cS,^)"koApCSI/}{`ri1p*2}{Օ-#%k%- b,o?DAu發E)Tj-qx <]1o/1렟c T?7h>5QvPAҸX+:[rjxfOI!7}W\άάT +.pA8lCOYN=6"w3e"VvٍȣTkYM[{ΰ +9R+ +X'o@j6֜:Gܟ"N}okILyu}i.-;BbH݀@辦qf9F|Hн}~>v1b􃘐CAE( qiTʵB[ M]M yV?+vkn^ 5 -XyDGy*q6`7ɵ!xf{mxOpç69}uuA{zͩ2B> +>Z`|m#Ҭİ\kX ?{LUTXi@D^w:f>~/O[j,Z4}5B:vs[L̙VP!8䊨tq7OUzDv]c03'07yIRSzG`a P]^Bˇ*SegIP=mm+PBMgxB$b=%6:H=nφlTL5ٙdCq멬 qkiG-:.Y6s+]X s*ӭ?:q`~bidMSoLN,Qm|k#V]ԺG/6z O͍-lG9DܝeqtMn IO}gU f +s--Fӕ^wɡ( y/+o÷^Q<ȍbƀkÕU{s^'#‰?dXZ~IBV1^ЬE"Tݔ)NiM}k/ֳ, fbϭ*5| "&ˢ_|FZM$J5}["I [V"(/ܨ}~g-~n:3LT墨p3D3v*K9<h"*0@jFT}coJ>E%Uh (gʃ3~f92-J;i/0d@P^΁j[}.F醪Ƽ5ύ7Y||jw"TlgQ_p+diUܔd(8MBw VUï>wkùjkc ߚѧNC>&VƢ)L~cQŝ~x{mWK->=&fWoMZKEI\73Z¹|~.#5D]qm6@}dìazrl k!Y|5#8uQ7rWqq Pؼr UDHCOچ+eIцK+q(m>=ݟ3RwqRy"߳9Չ ї#|}{h>Qs%'x c se22 +"&r"`)}Fdz=_؇#i]43] 6?nveKza]Î/a !B nRBBEJ==ṣNUrvD~I&,&-}{"h>i%N7dʑžxJ_|DTh3`uGRI +C}nNX>\[sv͆ f[gW@\z_񯵂Pc7dzPfIjA$Me=f\CL c/uG$Q8 U^lx,YduM73@2cmdG)8Q=cNo~~ǻv wd< +C`L:$u. p(|ҿi^<0$EI+4p}i$;@ZGi˘~@.1P}kd63G/#G|'#ʫkUSO!,ͧr43tt8(bݮ@e =5lgc鯉A w Q*&bVUҢOTX^Pvy"M `f8-@w^4fIƯU=3 +Wފ!׭B3G%EkW2hDd2P]ݕwyH5 B6ۈ))EK>Q!j:RY"G`Oo"D6 E께P +M}-^(:H=u?]qXs V=AkWSz5#na-H#H\2@촢c |Da]T #Cg4&Dp*|*X_Wz詒ɒC`*\FAKB0"?1y 3w~эXPLۦ5@'X 48O. + 9C̵v噝6bv*Y4qZKXg](-+FƲΨF}uocNyƍ^]~PeUf4F(1Ϟ4JQ1Cאs]QaD%J;دTFvgkڎd '6;#!. x`2Uw^%nܞ'`f|RpEm?E9ÉMiY6j<թɫàt|qG$ [`Jmw:X9hCjdM tZ.[dja u%f䳽E3,ҎHpKn6"ϛ?6BwD +&,%G>t=9]] @Os#8q @;FAؘC73gC?U{d(믿( _9Yۻ?@i96oH-SyFR*.@/Mt^Hs}O-OjPT\3S :ҋ$tbdQ2f|=iLu'W eJ ceo?!QA/g:A/M-S3rxt`uSӖ`?DS*׮s]FyǽGQiJ6jl<^P?Tj㤏ad+<ݿ. p'TgJd.{0tjFpOˤ .VEj:2S:OWx!vCoȣ౯1w)깰ʟ9 :n8bĭe"ÃjG/ҷ/g'ŇdGr1SSk-T]v:N<Τf>@2)U)f$azlK?:nje(GW<ư+(n,:R "OiNՈM%XǂiWkח2RAޯP#w?Ց뽳iFYJTzXB\# n4s'S'"]\ b" r{q[<P}{zZQ?^KfLֺ(gўoy (nK݅Vߦ4sn"λT>rsy3KNϞtvp{-" +XU~XlZbѾ;#[,o(Ѕɧ-X1&zS{)QJv";3ƁjVPs0T: +gh(1\x@"7lZ=lCb7fvc~$SE}- B F[{c"?#HYjmO=CmKx9-B7@3RXU\iw sS#?N!TJckY8@ΡA?(Ŀ~FN+*z{bu݁bO +KvAݧq9X P_r\JcUh` +Q|6e10p@Za"\!D-N{A]!kfXS0u/1Vr#k-|h=vD}$RqFR8Bݓ1pYUj|2 oљ|b|M|pa>N9eU8%K$,.4":K\-gy2D*RZ}aOfLHl: 7`͖.( it{P!DkqԬR6)'׍*L+>=,v8ʖ{/. +j-%h! ¤YF,Plr<.V<ՆoQ(n-D7 ~+<'ɥr7)=>J8`R#FXmO#1K鷜%5ҘY7A& ^بj=tm=T{LB-̓*d;+i|jipFɷtx5!LP?f'@3**Xo<n渚u%xG%szED(JC_Sa[V_zť.g/ѷ4/L}l΀Qc*Bc7brURx}سΣa%1\S{v98=[.{U?@Ar2@\kVY ǭHr3lkD,Aty;`5e`]()U?'5NA #o5S5Fӻ<\)L{<]h:{KJROVFnd%0i2+@I;iWqt:dz3@ *:HtX\~V ȯ.oƌ7:%Gfv8,!\xTNNAvRl`oe[85U-OCe]]M](5g4TEVʫ |/ z 4g'AG n!o^f3-Z?"+B lFD1CY.l ..8$=:Qi BZ @U>2AU.;B[!_7$-izJHFeAb(yQ^Sր߰=Ztr|eҍm|isDq3T Wt+4:v+E΁|ԅCuwfO=RG ۱ڬfkdhRh&~kDPUd(l߯Wyבy*}S^;5izd.֑9WlDPnk*w@z9LRYuga?zMt6gU_iA^ﲭge@|@XE"q4h&H[~n8'cK2ѕj{)W{!a0C_t&7ӫ%)7*6\ habA훨iZݓ0>dzҝB92O{yAOWa,:.NkG"p`?v&@xMG4Z:3"]N#b/D\z޾c>&,z$o̰լ1O{*MYL!RFAHq{~ N&Ff \L _y!d\b2p HE%NT* w|$ǕSx (xQ_ 1#+V%$>?{.`P4[#Z&sUP7. '^D1 +>h6Q/L嬤D;#\ʕZD}&A{dt tļt^:{'aOK^R_}U@ݱk?KbzT+ÎOBPAn#, MbIw+9}]IeϓPkP9V̗,fL_&Ms܀Y_"hm"KQW-!2Rg -+lJ^:AjXz\ sEaɾ-;D>nc뉸wKu ƃzRo +oJܔvhdvјg*3^p#ӳΥgV̢hZ@؉88 ƍO_@iAD|̯+ uA[ Qۥh_#__W ge8@y)(A}r\_m8GNsD0jr) @OA^t@9+ ddA\@nU;̂WpGFi8:ߓf=)}5mPm3׀Ǖ%ٛI1݊/m%G + z9Ú<R]̈W~܏2M&3?˹ڂg$41?d$m2R۲P~vq@Tk=2]=E. wjo`[:6",,C 80JfP;.(9_Q̂0࠮}/>š;ܕ("GyD}JsFFMG[X#)dѧѮS×KUt`Ӓ{~k_w xFN43*ΡbFpX0{avBҿd! +)|eӶLhK/zAXw !bO}ezkU ;ATkwG}bo $݀ tTED });9~^c0wq=r|9ʸ]7]Wbwv]ñ1t+Q{>$0;,Q(e>t|$-D:nP$:5 +YMTڭF?ޟ#YrBnNMџ{j V/*W?aqp<8:l<(0[Ə2 ^ r~TqcY=ψ&NhXu(Ķزy7{Gd+;d+ڄ]:oN~^n}UA8jhUk~.)= “ &l;b]1) g4wCLP41vfb#,m'vG I7\zGo,KG`HUG`SjHNu_1>4p!' Ycpds@Dz*bPp?[? +0}+_q!z)?g2ELCz*եDY}`IP] `V v3UEou~DH3y`8В;tEE:,:.@`^iuX}ڽಫI(?Ώ1UL/">02[ >0ľي摩7$<~,`WH'?ij${ %%5j|YQȳ khܞes&[Rdؗ0/f]b4S/(ծ'+r~' fTaz~ +R|%s0V.0W=4_4;PRk1;|H~>sOPuGTwԌL𥉉>k'ȥF/LFhq;mQҒ&a GIUy@T(~cv ۇ.8Mz?tW`g u0d`e : d[2WَB(8] YKrq%,QFBڍď+wԩ`.QT0wX+Dy+M!*_4%{R3|9Q3{`5# UlVYC hЉ檖7]*a!QݣHj7s\wTSb3fLWRH +J}}p3Fāγ^ 8CXAZtaTZMv2qcW﬑=[~!TL%'\F8s(&-&4Hا(tfGަJP~H&Kk=w 9t?-~(.l.aD\)B (j}GDj̖)&W:zj##\ . ~ϳ2E  WSq4/-JT^!uߏƶz|DiS @”j㈾6mG&C4=gbbܤKfW8|D8%4J9YÐ#/VT35h4)5nBG*(kܦTAF<lGUR3D.Y0 n_{689\8VKGD5$]l[@p=J1NwEp:dL.yrUfҠeGDQpF_EG#<CN5`eN^Ż:U{|/ؠz9u~U+jCTl3Hd;")R>_[u~ay {?8ЂΘDw?4޿Ld Kxn,A]ԯL]Feؿ C􎥠Kn]$#mfhޮ@eYSo{Wgd*mz+5'p#ntTp]^g"v[){c~׎Ev}R!NgeʧR9v -:X'X{{x|(;_Y/ATT<+~@[cѐ<^`?+"Aσ:9ֳarR +|8Խʛ=V gRwa(v/W?Q74aGⒻ*MRמuߍ'\5=l霯ϳJOUX4 Pw񧃑뵵#[뮛LP/ӽt||)pe2Ff_DzE5_|V%$q͘;uGN^o)^K/ʔf04mlv灳O(뼏Mn2Mݢ@}ыL:AFAp +)ND}U I'"R30>04+9G0p9x(LQwylTL=؈mQ#>Zg|sJ'O2i  P Hryk\?D/h&"8W@?ā8 &((z{n*NF*~P~DjDfX  t?dY\ #W.Q j^>6sO&.Ypa mSAiG HiyQ'|`5#⟪v(* Ƈˈ!?Y_qŠ<1|:+|@KУKt +Ly@Fr;ADRR%p\3HCS2]4iF~ x}3 >H;3"@Ӷ1i-*J,O52%+n]邎KM$1G=3ǺpeLZrYB$87Vgn]!O0(!~@52,eq#7T[~c8YTKh K<X3־8Nϣ"Y=#@&3;ZHW9##گکA$ +F̟ `Oģ ЎW9XsL)Gt,)LXON/h'Z1͔h_qbTA#A,(n-J5#P0~~~_`&Ans%e>>c?US}&Lauk?{uUԍfI +OjE("/́R "5giJ ڼbO 66 ' +*ma)7Ȓa&ƫ!@)G<%p~£gl GAG:́/s~zѓ]C_Aس sU)NɬaG[ I^r"L_#l/Tt"J3`~+#Ah!N,Ed%LͮyhB a5%#*Z]au|z5&[?I5[!J`(G49q .0e5֔a2\yVRAu{γzJ1bRooWH +*+u<*3g:߻8j0Yyޥ@x*8;p4D=#.6;j Z>z05G: +JO0PbvÂY|+0#λ#&>j Qni%ECq u h/I[ı2(a]vgXG߁^-o!T5Q6<Ƌ )gUx=Rڷ2N4D +thR±^ rLܽs%FxsZrg4e 'Vdx ͘(!IWhwPhwnG2}Sęf9^۷QRcL^XN5}Gt!X@y(AΊb_": T܋O1/u~h6/D=3wDm=Gd +Վa{UɀU;P#VIGcm_o'j-;G`+#~' hRr2@J} Q;ՖQ "{o)= +)߷I~zWQ_5Rx.W2yz ({;-eRv~#7csy?(yoLdIz$>'<)"KV\iY`Z;n ϞMߴƶ8FP'hE30_,Jo Rr';0mɲ___ӿO??ÿqÿ?} ن܈F] H(ѭ/L_ 6S tjGLMʤ+TNQD:%bsvbs1b!YnW'H1OםVc;~M}kW +U>:{麛7psBыS0M;1i&ȅRUf$g=*OQ\tdu8\+"#psi药0pM=90i0<K"+ 4#No7PazNǤ#(UF!G\]1O8TnoA^{ˠ((nzPe#ycOWC 13-~"j}HLvų0POB)!⧇#膐)C![yarZVAW;}]Q eZeta,,XY[+|vD 14%iP)B47Up-<@ݐcw i,28&"wIF&x< æqKb#;v\yܠ;ܛ H |-ZK^`Q;YYM>Ar$ik<*i9L|W(d{d&?c7yDeOj&7=mU%-r3-P!̴ ~!uX[c Ф`.h +i]Pc?cA<bq>0sjʈD`YK++ZXZy:pEq&CZQq^8p.Xa'~`WjnWTM`oe.nh8Y`ُIhu@1]Cic¶*Z=}O֍2ɣ֝O$Pn?y^dHl-Ǔ3s3y_gL߆rLv9p}:T$Vg}i.>a ++4k0YLe"0QO_Ӝf Yq"]sn7oj^?ٙ{^{#puDxG]hyqtUɛ%!{B_-hiՇ + *CWEHN<;!Wb6ȯs֞hC%N,t/dG]@6G׆jH $?#‰5F*E6Q# )Qw 5Ct+@IzBQݶA&t""LÞ"OP07ib.(&BtEsdG;njՅdzcZJ۫ +~N$ٷN߾H{O.xS* +@km*3-0]7+ 8dTиC +p$?fz0&Ft;NGG(# /{<D 3 ?Dt:SPw4XAmi;uxImK[3!ʱ2B&_g]uk]U$Fqz#unF 8b{[zN,9pٕ˘ +nF>J=./`]@1%{'uĒ\mf ? +(#5}ޔ#/mHZqGAi~4;Ak & +}`WJsHjIşQ B yPMIt+mF-itkV:GQaG] '$SHd+6?='~(#vmZJh%%rQwgq>stream +r9έ3%0Bx( 0&{bBk&u4wPA`<, w +3?S3n&'}fz\L9aWLV>:P!)OH|KL1n3 ;/HGK"YИ%﯂S~~l琖y+(:*;7gB cQx}saon`GW.1r3%J>yZgxh3 u"ȿ4$n{#ؿθ+i>y[PEf >ZҌaèV E ih.(3_4ivVߥ1+{&h;8a[[If˻rR4SC`>W#x˚vFt 2b2C>\/ i0* D~"ׁtd>{\$:qVlh RvApÞl\o]A'PqG؃ΊFD.:!A=Ԁxʳ;@W3 *3RF0;z(]<IkjL pȨsQȄT>5l;ʟ}^UH|ADgWtNf(; ڏyE!NhX?b1=0FX0przxGt|4dA7LZP:G"D+9_;pf8B/@YI?>9© Kbp: p7wsiY†񾝾⹀I\G` t4k`-+q4(]q/{~>#P.8އ{Naw1,L)T,F# }CvƧg)%87m>?]١kSϨyߝUi0, Injv$W:vRc֬]# +C"_ןf$cTyů}- +t\._GoV(F:,'GBp%ª"dҸOp@PIn3f{+@l#/A"5S#fd.[:~W:p8ab9~kD)aGD"X)#at10yPuACYprP÷fu}ZZ`dy(VfUrUVl5ݮw?{ c5,$bH6:D 18s%̣.R H9\V^AfJ/G6/xw]~[WtxVN Y u8 )\{qn ğBFF^,xJŔ Nš&oyd8῝]t#5N# 1S5oz!~E7=S<>2ir +q13 s"ٝU!i{s+Om \S86ЛU`TuW0u)Kw +'3`gk?E'v^WWzCq ;|*qH 3d෦ޛG8Y w=TWE53qqð?,񠁑{UIIrth N > ŽЬs|#49{MXIS+x Co$uF#1䇃 2d"}I +[_ϰQMlAt$™﹨}t%w#qF:AɅvt)ksgw-tA{->@# A d0D_IDՖ>E$> +2Zg?gf+>TPz~BGjRWnђ.j7X Y;RLp|&i=/ +5d5}K}fڋqRb߿z_Ûme=#'gI_hNmXq!Ff!ԋ$-IBqx_TU]z2d|L/,I>1 S?gra~~N>.= > GO,LTfo/}dF3. w<``Վ(IX? +>ɝoZg<TPRF^e+SnkTSy"\y,3Br{IGM[cZY]ľMrbM ]B5X8ң ᣯ$1>3E 9Bl}QQRQ^9v7YO~<Ď0@ +h1A`w/ GFԻH! 5lypBه#_RA FPї{4؝qxoɒt1Lz'&:řU.TA?]&P +k_>79. +vCύCԋG5 aApuX: }陖q$cń9監L|*v^Y`cEܞ@,}0ϻ"NͥRvɸ p0ydA?-3ܩ]@KNxJbRĽ9SuȲL\ΡG] dZn# `~1~wɁ?4"wwPsH39X55b6;Crm"(LUA;$4 r},k0 d{8HA+#h~laǜ}v^FǨ L1|D.3ʖ+0yW/rX#l!αW*#%b}S;ތ n DA , $np8v9h9!B1hi so]/r`eEi=M:,4'_Ȇ5qQf@zʟ Upw_*O0 E:m~m%nG2w18-˞.Ц +`(.K v@~%iE0?y#t/kS6r_Ƞqmא-#.3Qݤ.o7Q&ϡPZQ4@jfu?J@`WYnzt K^wr6DZ}f}J /IB2fNyZ!<,?tO+!s#0[igG>H :b"S[l¥ˈQ@tA&z@8žnܡ@f|lҰyB9 +%ʀD +,tOnHݵb?jTϒ#)v>a cD(QRaI<\!QK+LKGpy`p +jݛ}1#EӨxZs-D!.u.AeH=|qUK9JA\O:5qIp~OTg $? 3$ւ3F÷RTpXvlK%ϸk40bm{s=9# 5w| />4Js%RcƗDRQӦT9 '!8h(D })n$(J!&yyLII{ +6I\n~1Ð (v; + ͈̉=̜@ZbI +N rz7M Ywaxf`D;=ً.o#9&+{0ٯ؉-?Wuϖ<:9A)AxK`uϱ5r:gu1O i_Zͧ +M^{[owZ~p#%PRFDq_=)=YItFNϗ[~f)"lb'0I>ˡ!`]8<3 P8mp}% ™I Ld +5x)#:;A;Ӌ:ˈ.eN6(Datdyrcתr?Sdx,UfՇ+.Bj1)eݣ$9W*]IUn(l`&#agE)$Z,)t~ 1N׺!jM* WGE{̽>dA>C6 <*0.-)İmsv2r(v[0&+ABWĉF9/(vߑW@mћi m7)Ťk9nf[oH;ef*lʂ)bw7* eEgYR>=:ܠ3T3*0jo&cxi|zOl9wTHُFN/K +PM,w7:H#v` +dW(gC74F2~1eFA`H&bUVዄ&Xo/|̗"ort+ZS $ ˿-9#B?f!>tqƳV.i Ni "*ŻA T>' +] W+Z%@a^+01CuaDuFGs(?񎺯\tY9qUX +KA K;PcN5ȕ +e\1{~`bU!׫=dT5,K7,; + ZKRU[ saY$-t Dʷq>xq zR2X:tG-VtWFu*>O#qi>]x{ Hv,bګs0-˕ m} +6b `6O|`}4XW|:n-4H!ho)g/IpgjڐD%ˀ4)tLqc('QjԱ.8hA鷅K櫌D!)E}V,D,JUĊn>=3?>g|E17ٺ`Yxbkh9Д]Q,c's%u}j#i%buQ<^ǟ>|l^2ŒmشGn" dc`T074S:Ni-y +x'ͣijUW҈ +p? <@=JL=DXdPS9epOOīLLQX$ˍ:x ӯ5a!J|$rZ1k*\j֜|e"]-KBC缯RE.>WewElhOV +VUg],Uzci?GPs޵,"2_ypٞ<r4v];"[~"I K*+$6Y$8fS%&jЄCe:K$J.~77†g(G{CspCq8WV>4\`|z04̴'qT@cU U|Ļx,i@_İ\~A! R) J:`KX*%\M<wfgG 0aQWZ+@bXra6 }t+\l+&3Q,b| Pp=0S$YU Y|M"Z3g=&M A%1xΩTz H=khQ ҆gyMJqWN4Z#̬`=m0ۿ.gF +$\uՁ;N*gNL?=rVw$ +E(7;nHh{JaL#fF.ץU ‡Luє?pZ ~rT: ذA鋭1$ibľpfpZӜhLus!ʏfX\. aI]׫?lm"TJe5͕Lqv7I} zd޿.{=&w "JY4mDTgTgU:wӥ01ѧalh;/-)<4;}N'ӾQTcMs/xeqQX/U)pF@ZҰF؁XyuKVVk31qWw1MFr_kh|"PvA/02)Y  U# a 2ͯ4rx<+ir{ByAA`e&-Z!e9JxI3_r 15n,~`e[rQ5xi$"fEP:(4XͨIFi y \V˗啥E?ȃ M8D83$02)8gq'"/pؽ&<稡Ti#peQc AE]~zvJ܋73[l>H\+ZH%aĂgM)ZъkŬ sAV-Qlyا}_[\ۢ9y{BiLsBȼiDri0m̠da-܂a hK=fR!;)be-#C]"kQ8R(YB| 2>]V f J7c3S05HyNU.T<ܳgJK-`z+m H_#aƎZ8pgncgIٿ.ނz gڃ@`zc3sa + 40^&%@ )2yhM! d Q1@=*twr!-pM G|f"*R#WFۚ=i mD7," +M+c}^^l Vr|wUHԱ(lJfہ +Qm N<,Q@ o5N R8{ږF%B,̕.qhxRq[+1Y: iu(TyaQ|X|?I:Pw,?xLWȑO6{JMqW14?(=3:=y o9Hޫy)"[S~O8 sn3l(2, +o3^oCfjnG)=2tGɦX,{='e伾x.E!\pƨr3Xm&d+VbS 2,ޤfeMp|#HjeF;H(fW嚑hD`} |-4iz3fP-[KFxPƎ/4`]7Ǒ+w`P)=ԄzP u"Cư):Uhv!)g:W*U LeBCo.׉(cd jʎf⸗`h-t֓F ĤW8 —uGgRpH"N׏[PnQd~=Wiy +/l4"+ƴKa+yf6kJ'2>@nܚr/r%НAN.PĈMD\]RtZgBB +`NCHϚi}3zJZL &ۯM )m"2{.Ci+Oa;'I_o b$w\~b㐞$4?_'gs,"qWfB#V%Nj2$ɏzIX1 +{"൹FidIKӣTPx,= +y`{,-B-nZM`#{żLdq!/ybr;j3È~Cx'R:ӝoK7[IamU O͇~5AV*t%`-{8zT#g0ow(/dj|is_ ]thZ 6 N2zt +J[R8'͕F+{v`(qqѴ'[bW1d7w_(my ^If:uQHU"%þ€ t>kpx*:T +ptqk^uۋlh1"% RC +Le?2Z"d *Z:?\I :ZoF;:W65K-@&ΓJ;T`П,ai`Ls>~C-h|o Y=.\/W숭+̮7GT" iy%JWVҰOLSS+ec N@y!"|}{0 y̜9YwY07f0V{i}#WWJK_%/-D{P^ __}/S%.iܨI uL(gD *we tf4X[EF(]oPEؒ26R;F&}@vyNġ5%w +tBuHkQiN|w)IiáRJ.$m*:EhuC )/:G*Ժ|z\fណ||g""kRHtJDKs5oܘ)gdxB +avX)I_zݎjSѢW:ĄT'2):\k::dA Y,&;:?kokng_wt",$H'IK0d0Y|5>kN$ @8{՘_k#P6YyƑMe5AU hWf!!"Vw=.Ocܬ+I.Լ| ::Ee E>coQ[Y.v{Xt`#"TaIcƂ";ŬفfM;[F(_(97sA7 +[ck:"@ov9:-siOv"raGbE"ШI.{WQ#fƓr#$HT3S6~#0^:̈,4  +M7P=ocv,A#Aw# +̮ghUya W6Vge2:t;b{Ple{VVK;b*e}>;U/Gk^Y vl5Qmv]1y;O{NJ$G~a#B1Ys ښ [ +aRAV` W9! ,LmdϟPM +%9cRjP Bpk5ۚ+ZGb[FHjCܪ^!LԌr!Sz{P~ +y\ɜ8{=PXD +Q4! 2՞G{DnX+}?vJQ,`|i\SZlkϝB &ePLYBfS>E<\G+j"/[s> +hUh9>Dp 1 زt֭\ a$*@S%P?kG?gMڑƌ4 " +5~fMXw3; +~QID!{Ve«D4v>;Jtu\ilNmm?;}5a?WhFeDbis[zhqiOuhH9XvvN"rۼcIWɟ?=%vfmA3F@OG~CS f>C^Bevy)FQtGA`-wA#"jgq1^ER Џ5TFr-5(zxjj1sAy2_MԵisj=ICm:N9I*8!d=@nv*DEGVzm LR8l#Xz#M3vA 0p|׶ fܽ9ql8^¯~x p,yրB]~x~f<1ae P`:6HkfCllq8^s}΢bKR{댳o ZNV,jzv\few{MLbkvޚᇈ_Vc5#q8a^Ltw05G rE)'D_A;* +"s%`Q4 cq,0T +GȈou$ٲ{KV ]u}IwQAcYRE8`sQ^Fk]7(֑hN솠qI~ȑj={ϱل7+{0Ѯw%O\@ٔٛ:wGL?ڂtO;5Z _4Ђ.wTNٮjDllHVuXn+L6ũ9PE9xJ[y.ޡ)N- %K# ,5*Ŝa-wnHkЅn»F1@|WQ؍F\3!v`!vǯ *_R():=0[`Fa];pMt7JnvF8r6 =lOOrf~*4J97mư3Tb}1h_vC#>oAHՏY@ U>>Ú8vwƿBh~`etAʅUb<,$uOޅP0nD:ٝD Qei"Ӻ`--fjm{a[ .DF DmX `QM@ vňF(IE Dyph-b?4GĎ:1!Z4᪣Er$g9=FwrD 5꼌Ҁ5 +d#n;}>iؓG `nqq| U FhEzI ZˡG*Zhsv'(Jg˼1k;_B2fjEPw3/_ +ixq̸x3Zm񭀥jLوRwH[ @ 9P !1Zͨᳫg/M, @zIHi3s|{'#>00'~3+Lmg|4!J4T3)P͘rTt4)J!Ujejw:X."-u"rZHĥa{F&ED d,!f8eoY NSNWoOǕYh}vQjj09`!^cTnwו鸤 +zk?G~X[4KϱcD܏;Qhհ2#Jٔ %3Z.聪S=ʈoP9#“}9b.T{̋S菪. |:(f(@bX2l&s =qd! x YݜHz#wzS阯J,u{3A/Q5h|+kHy܍tp6JD{\ٗ\O".&S1"vA*p">AuDdSF3]R"R' {t*& 'R}Z@ +P*)P)@oύQ'"!L0L(Dj1IFNbx~n6Q?s[T߱ Lڽ]Iђzo>r5oz:#@7lI5u +#X=mcUѻ̷51քU +ۺY2oG@H͐Pn4B;-6m؝@nNGÈ:=EĦA9di +2R0zQ-IY`iNV%#V:$2ǍEACtx6ql)`n˒4yEx*j5=aďT}{)_յ[uYE ^g(){&V{4P"5j[2)dQhaVfZfI!ݸ9ѣ9 gMoZe>,7k8s%U` =Q' p)Ǒ>i1Azk&b8Jt+__TʀoK}R^dch k}2Jvm,20#*Py\mLr+m8$Ll4aOf)جvn`zFqH#h_8 B'S'm7g{ADśW5H]ڭ0= k]'-= c"rpt=#aV!\GPs_$`<^d*^4Ea#_I\uEŵ\+}8uu͑=r8myf3*g=N[IB_{fT;! )ϦL][-xuHESwD=Ŕ; + 5WV^N\1mugfy ]9r#NX"1:BԳ+T> t?l-q2wM3崙h)`tqu{L;v{#UJWXKW^xd.!Aj!.ӤD{49RhCDzyׁTb >vf䧀JKr y_[˧G誋D +)mRJ*~5|ZTR&`FҨuVsSbӨx`Hyޡmfܸ" u#bap{⸾=pI? 1S<5PQ?Qnnu|WXk:aHD$Uzd_cA طdNwb@aJlqѧkRZ. CC_{+FL|vy4;̀<@'-CWiB𰁉oh)` +w-ρޢ!OgH7';AED S[5М=" 5}L'$~hRu12BV*SHߢ+sJ^A)dVo#ox>r|yU] 7pprjWoߣFܕ;Gf;LG+#Rs ="wVI+u!3Qvϻv~w~fQ]rԮ4Fo^Hxd1bGDž$X^\%ߢަM{IquhLliɴ❈/nj1pjw;0GYxᒕM?8}s +>&eBooHrQ31ѡS!/?&g~_ߋf jf3. +WQe1Ѓ~F+.5څ)|@ 9TJwڦĵ6IN Z8 @fͪ 0[JDy d&DP~Y/7.L΃]wA-= drV; o[Ԛ߱\m)纂IZ'& ӯh@Xʅ5a qV,֥޺M4%6~8ų̲ޑ93Lrj5Wr9uڴViK=j dPʚe>3 59靮IоmER8K<SbȽFhlOጢFaNXGiv-ZjilGԔ]t4:ۍ*և=Z-^:ROVΌ޼X)i=JJe- MKY1=t`Nְ*/ +gWڭ;>RxIŹD+p'YrF 5f5 +ғAZP5:ЬmW`ߑVIGMs۹BFEaQgzZWGVE!kYx(8[p?e +FLTQ\am!,5בSfU@^S^#mUAovӕ"Y%ZTGYjO p\zl-."xA_O'~,izO3L#n ^&xuz*h7\杤j2k#;HI9u+ +OB6=xDWDkAAwE][h*Cǻ0Q/[T`c-u +Gg"e;J"jE]E_t-L(01 ebN= FICMshfJ`kÚhnG~6Q֚m?:AlfEo2ҼGopƻxU3SXU{9|mmQm|LgE68!H8"DM-P/{Fiϝ}{tΙM +F5={4+Z1ƈ,0a,Q:a0Dv |â߷_'۷ '>"\XGyZ EM!Qۅ}T*a˽,y%k z@T쁫h. J} GprvY`FR8c+FFbշڈ7'XSX 1.[s.H@ + T1 (9WR+82 VsSx+cq mcS +*Q +K@ nd'L+FK `02nfHt3ۮԭץ=?hֹc=̐_[]'n7ZHo]TcJ:UFD$Z =w[a*7*. B] +գxK-ݲɦ{dӷ 75oAk +#B[+,?*(~Pn51^f-l0,}+*Rȁz+-0wfkk "VjS!&< ^F=hຈڏw~j5SȏD2-$fFMƎ&DX]Җu1ϡ1|쑞J.": u;CGPh޳aAixݹfX%d^> ~tYlw/q_k쫉쎵.JBPۧ^tlX+#?iI[*75w$T+FQl54](z9r!"oRFb"6t10=z.jy#*&qʞH`KZT`]cgW  b4TdxUt х}к&~I"uEM_sd\ oѴƒӇlAY +YpxMģӔwdwڸ z |E79κH_Hk]zؘRa܈ܨ׊#0X1SZ$uUt0Z_>mpTU+.,g=KΊ}ps'Ɩ}GۨNZJfbM_`{VR%$d*gX<"pǗ}qR^P$I&ۗ%qmk* &m#<ύ 4U"ZKOܺU.2LB_qms$,H˳3mrDfD\XkCO 6RξJ=8@C8FNyjU5aٲD@fvGCD csAÅ99%7*;esj]O/-Z̰zgh侣5S.U n̜q!tO~_t46yu6D)qG[7^(6j54 +\~e?U_]`L KTw=ITi\@I1Zr`'׵>׹q1)6}M踒wP E^7p_6AtzaXuUbԔkԔ`kLؔ@*SX'ʈryRPtq^ :un(ح(ԏg7 +hp[j_?0~^\MzVn 0__j!#"4x^TglQr GHwڽ hjT4dXjߞ@ +\?|>Sʑ5q"u@.Bd?TwiwDm[fذY<3qBHˏOiY_m6(ϑnC[Pxj6-yXB(1Ϲ^0M(u2Qa '[v1L~T 0XMGVÅ +G8z.C=fBe6S.$9Ɗpݶc)߀V[.L<xgՎ9F@P]f2vq0U.ְֱ;u luޯ,\0{ܻ,["FD +yNdx4KnA-Py+>)"IgGv cE-i yHxh9l}qb[w?\_RA{ӡZ1Yuth/0oPt[}WdXhv VRQļ (kwIG<̬ٚWەBt+~AbO + Ϣfy 0hAe@U0UKSe_{;=Œu#J`b%.EQǖaLWʧbkB.WS0BKDZю0"Pr$jMT. JM6F $ 0JtQ/Bg.Bk +udE47,yKVvsDgfÁ(xE1t+~g\e[Ja=2 OJ)>OMBSr1#OZyfKpG~d[ R>skbj>2/ +CbӚGH6#8Wh efV^,HU,χR6& קYG?]7cb,%^Lm|Hut @LvݵPcfTުN#q.oײַkc`ȟj=%y~jWBtBO%m 3{YR^=ނ&_=H\2x9uO?C@Ͳ8疋 pMEfs#5Qyy||<ǻ \(];*÷ U=R׫@nR!hUqƣ#,lf[@-x4&n@]S}{l)2 ]P[傰pe=Qn(qB'SJ*mGjd񐸮=[2]QnY~r:IJٶ7jԼN J_f;ڤd^R)ڝIzWl@BN;2_mn2)wQlj{{VtypEO*+-)ZvU .vMQHҪ&u+i%F5iw78嬈KȉS\TOOn s^vhQaXӺg3rT YwJ?3އ]Hص m3CQ:(b)DUongJN)hsF3L# wIS u@x,&<Sq?%\ ٫zf^O5[v&oduFu#޼~DMMA*ě +q-UA~<8s;e0g{Jef9$z +VeN#T8џϑ>s-sfmhnoivH3Yeql kV4~w!„J+oi75X^v9;2A9A l kmZwX 4ʁPn6_'ӋAAr99՝] pOibPKvuDޞl {)ȫ(׌Qf"bvbXPHy.[%|5 &bFZ.(_cbm!>% BikJ c6"FR7 Wq]i `EٞCx}DNUweGբX\}NЬm.?-5͑Pv`a7D&Td dp**` EQs[//-iwЌMF)Z>#H!|CؐЍlS`VIVϧ0п} +Ꭿ&iuNT?)w岣F*YuKNAN/cu NI@OŹgx=3mY8R|<3:]e!UZ̓GQ uOi +FZdPP0{Cun/X•󇠴ԧ61JcD502df@!} nm_n괾4Fd$wjAzNa@H+I$1hzD*^;)ңcm%..'q˙7BygB|x۽jtn酅Uks9 +h f^TZ*bi#HrhľHI~7"#*L fTXVXKsiU@ gۅYa*V35 +cKj{g( &ImVfV}\8|ظ1 d jvNrSM.|LLmtDJ􎷱؃*ls&[Ck>t%Kܚ6KY(AFY=EtJ䑞H2d6~3;4z'`uj`uXm$W*YI۬cj]aIF4(Dn@u-a{[<#4Gļp$GzMnJ"Qi%CTF +t?fd4?|ğ/gŤ>khk߀(3OMnRshwwvPQuTÔBa+NcA[; +ʩFgc{Cp8~,c3fjG/4(N/B٦"d~<-9RȠV6F>p"P FESm|dJ nf-1aQ oU&3D 2N|H0ui6P2HgH)iF]ۘLҤN:~oM|n)͐^7rEœ<4X>>cFb\/+{1ܖg#x7]fXŦgX\^~%AѨ>,&D] +w#)>#yT SՔ-;{j*6e &炎t}[WrSԃg.I5u5onnPP4o8sl%aQR!kq)eQSy Ҩ~˔o!,XU[+w)gPddA/(5 (҄DAuzl/%OAMWJUiN!mӑ.tEf +3fUVknUBGuU:ar1z~an A` ' ׺F[H}n" 3BIN@B׳@RbWZNIЭC#)4SY`;]EjD#\Q\7NV,ЩFC2xAoT QTF +uk>2\ߡwͪ^Mg}vd%ew47br߉X(l}t<^o@-.4št4B'mKOk#d oUucx!S[,a&kˎ`\[3l%1Cm~n-0CTJ%dj-){gwch4FL'?_쎡R;l#ep,}ցL"9DFx=i׹m +9Eڽ)P +nQPPEM Hq &JJrh QAw(;zL S"30 ++IqnBK~: kcvUS5-o~w|I`)%2u!{ot #/颺'㽆r 2^'XR֞Lt~'gόn̅޴ϫ0*cnѤ2 +b#0YE{sCoN)) 3a^8Xy2g~umG +??ZL@:<`XG_WZ5,g{HYZޏ0=2!zDU+YYr2 s "JKw+Vx'NmUJ]j*]e4Y*/ +iѯ}^E+)۔ewLRLi 5iVvB'4R2DN5gͧ [̼ː)2MTS;@!¹[yE*Vg/s!xEG!"AT? ÅT"pʕ]&G$}՜F@' +ǽŎS-w# p_S3%F^S#"H݉8yP̝w:HX@]ze!&ҳz5ԣ57-]2CA\HO"="RVyr؟SS+Ë~^9̥)SOq?q':E& 3 /- G~CTYca}?)2nkH6rPx w#xCkc輶0ԕ= ]Q.jn4UUCyT w/Eu*>å]:Qk WuuR݁z]Ě˕WF$LRe# G|tR4êE+muھ7@!bTokG{Ա?Lf}$c%~m4UKuoy]vFi}? =!uFa{Pk6Aqx<_ߢ^qQVi9]9,BQ9҅} uֵ+4|<m l!DF伿 tS,\&L#1~l>L %',* +GZY߽Ue\ƒ)i೬m+5AUA$+L+\@jt5!Z?Rfpc`Sk4 Z6r>v\64H=V?qajU5j+{6~Dh0h`@@)-4@<vD:q托( bŜfꖑ_\s$%GZyN@>0RTd@dYB_lNltc+M3e{-D5" s:ULOc zEDqAbFgMn`-/ʻQk+ + 娰)+/sv o8{7u&^N;ʗ@]AS;qR1O*WpoO:Z +y;f7ct +(H&6>?\En(Ǜu1["o @#]{"'H5KeĠ:P*u UjDρ(RP$Tֿ05WyaB7W?Kkd=.nW*{ĵGݨnIK<{?2p@u5ttF*ODl[{_ l+v`\l+ hۇev/ A߮KG/@.gnZB +DWwe[gfs:[Y#ȅ w7+Och &5\ @TWVT2 4Z@דiiiaR֩Rj #~s>|k?{2H#DTۈĞ6%c<Ǯ!o^?&:w2u*OFج?t?]h4U2&tDm$T$ c +Qʞv/TD+aTRWZw~XW\G(? 7d=HvЪ2qx8^[2j/9QcfEU32"A=b+\1{ 1A*4R-WȈ_t2pv; YQ ⤑ygeQ4睓–qZ(!N{U/OQT50j?ٴZcj*H$ sE"úP[$xR'@Gu&#rZpDza?*hCϢo>X\fQxJdž(6dM3Y45@)̝ b+1C1 1 9㈋]@=]~"0p.eR7}sIe\4Yψd-ۑ,Zɏ榞yWSgDcrW ?qGԱJB[n=Q~Q)ncI4jW)ax:%J gr.@#Q-""v֘r 4/kl-P-bLCuegNy+">ngDX5?-5@V`dE3 JA-E +MA9[ '@cf٧@-!(W`z=QvEhURxƮ/fd`,GR'0vёKɑ +YDC wQ^9#9AmP!E; +̢1s#+2_vNz*}l!D4W\`,$AuDRbHY 1x᭤f؝Xn#gfwWDk>Jtmd9x}׺0B)ZC + .(58A>0Pb'͌G{eXR$?1{m:?AZm²Rj~? ֍m~W6(2\⎪3E[*owsB(uת鍝'l)ʩ6qͮs0eJX9z؂OlxtĖ)[+­HB.Mz4Ѥػj%Cz50\ZCŷ&g(bV iP\ȉF `QT$0'AȖǣ_CMRm"t|GT \ UuT bD#+LQ0LT7&TňC J8 ͓Sz3i6MycՄ6,V0!:sT98@o9rVDnm~}]u}IApX52r^ +@auMȣާ$#CRXX#St{uj  ~.CJQB~΀1xRho*K'y56ܾm'%~Pc*?zfrGM/x#qڏW"#zؾg,Ó+5JfhS•E:-X$LI;4i:l`)(\~= ŵ UMmMH!ӠZTOD+3^tSQ=On~*{8|e'NIg*  u*-898)({ڹAܬ_DgsMeX+ۭ^Fw="'8۷ +^iz|^lHQڦ~@Q a{;~K6 +n0tElr7JX=6'OylZaW[c *Hk += = aPjH ]QS".VW|6UVhs[{2 Vo^3fV7g;}x('΍J{K)ZؒTljx%r\.Ms_9{x`Sqpǽ秫]Me)IHᏭFV>93B7.. 扐[ +92| 1Sm֫dhoQ^FQgplpFގB$z(5q7B;|K,,Oh#-*EK@ArD`ͻ;jaό_8m-yIɋ3_ aՇƮUW]Nn_"66,j;+(|O-Z9.M^#y{g"|"JkoGD 6&2""KL֤wHp7EۊA׌ZѠoG:>~5ot -C\=Th-IdKSՓW hf?qD͙@)@3/Th6(ĆҀ]I*J=¥1%TLLTc׳};Tu۲o188]5;4(cXp{J-Ђ +o7NH2K碑eB"hE;>/ ؓGrwvsWʏo__/?͟[?ȡ>ǿowww8:_'7,',B+o}Hk"tQH4Ïq=R=ex%uv㎠g񧇴lq΋+ +h +w6V,UOr`oŀN`GlIiWyC2[/6XPp&STw3YMYjkޕe2Yn6?fin.&)kT 2QrէK][UL\9 A#QFIO !ۖؽ *@«;NEُ|ړaXMi丈uFI7,Ԩ-bt{wT;,z { +R}B5 |܂JNmϠ Ƒ1&^=vZ -͟[ņ5_ @6iϵ$)ʤItDo/Vo `8'Xh]෷1 +ݹAKit46l+y6-U~V G!+B+@# jq m [2~n}_HN'@ +%D!6',. +T!̧ȍF9dQtՖZ'f],%\*k4=>GP_Gpf)'wl|esdADT]Yo-6yyȮmE26d9*5fv +V+[OmkhFlQ=ƮP!7% jD;ѧCTo假s(3#r?ue N4]7^TF +C3:"h#Y셊7=lw.ϳ2]XkhP#Zi)8L"},,֨|OFe.MP5xkGMGPH31EW/i1e}QGiPrn?/TSTx pNəts|鑩$/p*.YL-S14 +|#* *PqYIB|d:P {4x.ˀF@,zIl?7d6Ѿd- AЁ!ѵ]opEն նO$;G)ȅ8fغQ7"[]ЮqwZX¯}"}>(DN޲Rj*KZqGs%%nNg5#NF%T7܏h7Opg0/`5:׮/^)+Gc~CziH0Ƭ1<:nQ8(lVT[Nh q}ֿt-[EĥQL:=˜fޡ'q,,>^ݶs%_tn1^ԩlGAqm H'Ke)ڹem[kvU K6k#Z&^?X02S!R Q`oB:~5E<PǤ\_$VY;{6ψFAY9'k#}h1]GLk@D)V(qpٮ#8d.ɒqK#I-c{g^xl`4QM?)SN?s_됛F$ׯ_$$5 +>~ #hHKo9H58Д|4!cJgF%11xf ,}5TT\ӷ":dU/zv8i`QrjV-}ZbS6꺩#p;O9Qv]Bye.)krc".&Nx6+~}ړ=+.1M3FNSSNk$P{' 3i:U*K;ˣSTaE辝׷Cma#HC^b"/F̌Q+58VTq=D +ٔ,Or-t2PLff1`q)=i>ׁ"ԤBgD!]b&yKĭuRSE]W2Nh>&6p3rGqxx_n$! +scks%k1q~:_GzG82MH L:"2Gk}EfWsM}=5YnE +c}υ> q(>B`+0c-{DSۆ+U-{Ckzc Bc _"M1,|Jk4j%] + Rf635U~F;p>l*纂W jOhivFEg4=>:)ADN5v2߼Cw!y -ޓ64hUG(DgiZ iMNAE@ Qnz׌6L{pbH+v*@َ Ũi@P:ȁm}EɐJ^r;,*ZT$ᯊ׊Yڤ_5[D*~UET9(%&ޣ؉{,Aeu@&R=ow}=e(Әm?4"Jލ51]@G^1)y:ߣT0R޶oOlS6@L.mIu.AAٿMU vվGثw 'oй?PMq_J~ A`?jHR>9L,tZ4DK[!cqQZd'Y:|ߙJ 3}4FWpl^בC}+AfXgQcc^Ppy\^c |ca,v5F6ҙ mFIta=r2[{fYL?/&,#`yL+87)@lPPweVwg)P {4ٕ]]].eF7O k%U84h#.Y'mn3UE 2؃ǒ:0)Dn$ za֯RWX]ڣ1U1e@>zMׁkVY)7QRj~W,^#OWx8IIZ@on)d-*R6r}p@88[vy6nIQ%Aĝ;yUbo8(ֆ +6R&!->,>v5`|?UQ<@꼺'E6ian^ 8PH?~ևur ϩz@vA 6R˶"t(U'x^Uek龔.ay~K~ 7TA.,IiUm)R$> *H 2Qz{q[(p̃0eI3|LJ>2LIAAZKeq +蔰Oo[X=[;ik͐V6Ip,dŅFNV# @aIt;uFK$  W85}$CX\ay]\P +-آfV9Oӱp&u EHh@*t8\u9gqkuS*Qb:tjDd+ͽYÕF걃lW]b+GٿsMiеQš%^ H[_]RC|Ml)QNyhGTf&F?h(詆TU7=@ޜ7K.`%KkȨﺨXд~&ycme^O_sv ,@!Sn>s*{LRїdҥŒC*e?JB'8_Yn(6Tpb8ln. db6[*u_+< +zɛ"uuxܬ:+c>5"}bS-]?iAI\$U!a 7L([1vPuyE*ˁp52wJ& 1.3?aՋ׹dydVTNY$D@ězyN4f0L`+{7܌cc;Gc{Ew>%*Рyq=! ,Zzu>hKy4OGUJo2(CkA!OFΡ7VhAfj`NȚ"\ QHU[_M$6"͈}/^!g5";Z]/RICUy+ޮ8(0eR^ȗQ6MdtlPFܳí2rCCFtDp +fS9l]lu^ȟh7. +h"uĵRJ! a@E +̲.8?xy\=(!2Bq2J'4P(pAt ̈9x sYSq}6@ $PruL1(5!oPh;[FM ƿ(lArn+͠ UUbti(zR$([G0W8~6J%`$Ur' gr}ȕF ՝_%q뢽n~U.(p`#7<ɠ;GR+wNr`A,gi.:Ih$2rG0hK|9pv5>|OexẠ{ Ҩ;ra՜9Ä'U)pcR`Ϲ^ +ўa2XJtMτH(  +GjtaꖕeΖ4V3\M&ɣe\qa?畮di!A79= (.\K{瀒hԀA|C& M!x ~-,><_weel-4  '@@ZUG*] D{ʹ#PM=EV@AI^4(j.v/$l'KV7Յm(;^&heW5o/Eha)^N; $,N5? Z IED]_rR +(8A]duhbX5i"uҨ + ia1%fWE_d%鋄@4U(׿{-Upۇ6 bP q8v1K&l ^F#aV%vdKHݔ +(|NO @IgKY&VE +C$&M6] cc rg(\?712uO T灰ŹfRx\KNQ̊J/j< o$uL]@9ʐRp¡$=,(Uk}lk'JmeˠJZS' f+f>E6 d4fk<9T}x:XS+`i)%UӞB̋g :-I]*JΖ/^6Z ]ItP?%,򓠰W?ȳw!SEs$C`]>CN>IB(bA# +LJ1B +3:d&=%aG\>bf|ӗgs8eVe7iMvhCCsE~yjbe{F)VQ76 G,ȴE<$kSC.s +cQ#+ɒgEbCK-^lPk`4@luבvɴNw0=I'11HKY&!t`wSvh@ iut 8.uD'[>j=h0FHF߄dsmS~sΜH.iVXΫտ H#t_F$[ǨgUtol&k7ULC~R/nЀ"vib\; +)NKG9`ڑ񵜬uIL&lqAJpx"2FvRv`u/u[>E, +!Z: 1i0g鵼`e28Dq8׾h&V}Mo c׊GM hqRB H +h+ }Sb}RWOW2H%40J/K78-N#ۈ*," CT"P;Eu 6J5] v΢~AD PҊ7O>k@$hCJm7DZoǩy D,n*H0$ WK*I4`EsJЪVD AG],x H +!?<4&Qޭ}u?cQho qCV7qx^E=U(UjX7sL9!>gauA +7)rH!֎(65agD4fTD%0=A/_Y%Q߿Dž#b>q؏&ETDRs5X OpGlB>d+ɵ!XQtzgt>#Hw Bdl&E(.ܮ#,7'8Q@E6*'<M#``,$Y(F;A'o0)tWW!Ny>( s +P<ꋑg% +Oئ{Saa$$)AUKؠV;l,:Crn5'Յ`KpOy4ICu1I{^e6>e_9D;)<]zMLN?F>""E"WR:GK_9Ae;}Rr`Cp c( +lM}0Hѳ7D0Phw^D3[,>e\JVpbd kpF7ɒlīsq iй),!.+A.e J . +$C9ܲr6혨S SnDAOx!W9~5VcPAkb>1/ +U3U~D*7((+S:.$&+}IQx7Q@.r}{o1,7xF Q"u +( ;C<"qJ\ =z8{(+KE>[OCRfm/a?t:u>= Eؙâ[8RI{N9_"K{5@-PTpBVz +Ҝ!#1tQi:ʝtVJGv!+pگ@\ڤ*2ؕ0X:ODW9a.}uH.TPuuXu 4\#nHV6|.˗: ^- eB39$$ecDPJ b{t 8 +oԎd`@SpgMЫ:Z!;J +dnaJHYL(UY m(:BG˥ rD2Js"6K `Gz +/4>n!NALlLtT^Q1h^*= "pF9U $Rhv} շ.CE4T->D1eQ SLu)a04yjRq>w p W'Vuf оLUjQYa4p<]eY^/'Kg8z]En^?f(ɟ]xl2R&X?>$4t;P@,F%htm@x +(y1;Z &K/NM p>ؖ_ XPU]R*h\;C($c艡Q&|Ay\\pGo +e=V}dкWtuNɊNyQ)cyY݉D]PMڒ0>fGQϡ#I2(y(t U請BBƧO, +:Ghk˰^J9e4ƧP:y:tPQǤ[L}54D˭ Ι(|f6M̸+bݓ$V醰z-+﬿IE193x'EsCkyL%^\'FB=GT?Dg2XԨš(8&]h7hʝ'(Hisx(V!ǔ8)?[$ћ808?\ձI p@qEw۹dWc_ώ0*$sj"(-zj$q]|9̇e)ihn#\W:zTͲK$q ']U=FR^OMwn e!L< D\~>%ɑLq2_ϱV팰 zh_&PFT U%RǃC`ݝ?͠~3!2[t` lrrg$ycꐴ-S[듔;v@خ;v~7}A ZܕCP8,(Nt-B:^@8RQ,ʌ&# ƈ|MNe@Q/z~|3 ԯERDBzalD쪕Wŕӿ&Fz}ĺlveZh /p$bYȈyE5?K*&fX(gԽ>'5_Uۢ^NGe\P*TBqQQgǓs/m`lʋO}0:tT"<^˚csf;Aʬ͟}h]Sgn$ʀIm*`Ww9crCnrkN'RlWt6f%;@Pfټ4E+ Ԣ6 GJƸ#<1p%\~"`ǡ#n^Ly5 1Q_eR,q_sQ!=\G%%xSx}6 +5 ~T$ɖbY!s}|PHnvsjeJB)`VSZi.``:XoYYTaG\aD~t# "W^PF꽐 wL0jDEsiҘE,= ҵfDQ q)D> +fRI,exK(U\IN,`/"?zLiJ7OJIW~'^ha16| Q֖%J^B҉/D .px0A.ck,[oCNDD"0Ji-_к@'iCtIOvG!OC*E}!zCt}R,2? 9 Rndsu"BTiMJ2lp7:J \q]0FH4MfG4W. jtGVډuٛZ~ AiK36(D#1wM20h.B e~H« +͙A1֠ohIJu?qJ\9e-\k-{#ʺM%[\WI#6X'ev ~ԳhXy;'63I`eYG1yY9 u>c 2 qu}TT`

gב&gP`bHu7(*L#$!]tAJ+TE'=, 84Ty@$jD,r#kFc7E+vʥC"WDȖVl8; AL ǐ$Sk`˦< _PEpO7ŃFy\k{8"wO*I@UӓzHy}G8 2!]AREE3=mc;_!?ZUv(1ltm.ؼȾְE)r 9?7p&|HQM@a% lDKa7zAyrHlCy<ІX\dNh $j\ m*SiR &/$pGb {_v$ '~ɽfAI"1m?iw]xdR +vN2d!/rCR[U#TM?͐H{K#E9OEK {~`w:~,$ UAj#Ap?|>4bUĶ'Բtݿ IbEE>D ۨ/g`2ERӥ)ښeGc|VI2|Σ.kK|D\\QQG9 TSYIOPr;_!J+R{^U>®&#bN'4yobC/R[zR. /k;vMonn4bc>«=E?N<{ĞQ Uq0P +"{'F#T $XjRy\7-sT觷Fsp|4@65R2*E # $ cds  CJa\w}C_$8Yj 9&LJt5}S> i #3K}|>>ra R.2쐪4BFHXw~)J{v+/ju[Jy!MR"kR" @RE@H~n#)>''6Dz9&(8Te?/j:s cS9 :A +$~!7ZWx\eo(T|?Ci'p'IʂK7)*$9  F Lڇd"Jez AlOl~ΎqAhV/e<(`$QE4w E!i>Bµ`9ȖuF - 6Ox="?.Xb_p\H-FG*O;G?r-0 ;L7#<=*bwD33;Q& 8<,gQjkUm l=?HIRE LYF %FXvM UyB?(@(oHeٔ=ʽ:Y 4r RZ6T4ؠ=$[ĕmObX9%{;KQ,w0g}㱇gͮ֐*)Kϡx⤼TP݁Ils}uTsNDrAP` 1u f0rҐ)ZQ8dc7BFH~|Y+Oe%֔Qup<@S:PXH46y`Lj +n șDgTd!hA!/P:16d '?@~ǤRQƚqK$ȍՁ.C;w!iJ hNJM&QW* (-9M_SR%c +- I-HFߌ!Zo[eU -BR ·1iSc,BۇHv][őMr*B/pg/1'` f?(MS. `ܭͭvJp!÷ǃ3;SòtD+DEMJwq +*bj`Z&IP[9iRnF2]Rx=AtĔ[Gs"2H?<FGJ-v*^p xkk!)N̷):,A L鑉R^]ZB%v5vjtŃBL Jc! tJbHNT5A +5!ZApDdʇm]Mrq5%9ȕ9uY3tp%5um[Gh&uE v&ܦD"JFccCW2L:#F{vsQC;R>@ʆVLQZ@3Jݿ܏ML +ʤE's՜'=L@\,(쾗:PʰS7'SN% iٍ.5A9@TU2Ad7#zF=gAoCs̈́>q<(#׿ۤsM1tNR^8҃$ 5vR*˚D*? x>#B]d-4fl0! + tz׮N/B(zH wN 't#XcMp8莃Dn'V/ONG` +1\`]\f)[||gwazIqrP"~_)m7tAȿOmHoBmBŦO.WqeT@#5U a6I(v\';'~  XE8QiFzpF!qO(Bm RK.F'>T!m MG0e8PlE"x:pF&®Rg )?R[HLtc)>D!}Q-ʎƝ;Q 䀄ޯ-l#"Ov=@eTVBW7UFS:_Jrd̡u2 +$Ƃvtk&eIepc;M^-j +3<_Ζ[D: +s+Jlţp+6#}T*~w Zy[''4UMb}opH%Ⱥ!Vc(["*OJV=dZ=(`5i4 lD 7F\(y~O41#yYFDcP+S%Z DOF#`F@wFz0M:K2Pq.F(RGI7Ƀ(ܣ{ +}K[8bNTh<*]dW@H(Ў!ʛ]r/?b{WB#2ɤ~c' |F$)C2CțRಢqeBPݚ^тI*(C?cP 6n^~MzxaP#n.kP()nS9y|TX?~zb> "ך\#^Ф3YR^4òew(4Ű\~zrH#Jf%H,#HfۡHiH#p.IcJ +oէctɷ)xgp@ԽFZʸ,7 Xw.Q\_59".-w/ 8q՞+1MCFGUBIcdH}g1^i+!VURx_ ^Bmvޜ)` +/~V'Ԫav(%H0e<É&_y&/w|_zFv(s|z81Y FTi>St8 +u_A,~ }^w e=id[T{HD^oc4eȀe.>Ӗ:-ߜ4^!]hJ +a}?]?D[&o= xP) V~&%J8]@5fqMi7SΣȨ(vVM GA>7}D_h#Ǥrts0$Ww(*L^\$6 HUʇ"K\Vy=i}`)n GG11J0I Dlr`$ba"|-"7kk-Q@7 z9* eq^\sᏑe! +vl5@u!\iMk۲Vz7Ёp_bi9 , |DP> $#= ,"@{ei.%I6Io*?i|> 9@MEeC,~b^TU,ͪ:! Ts0:Z}ʐ1C&޳;}7h!% 7K-Z+CP=U<9 c.))3#ҶV'Fa2Ss-m0"L?AMB֭PߧK;UIyq&DE' +wR Nt0luכmE蠀mm-.Əg:=ՠeKkHE M"fP_1 d׃QOV+(3ŇHVJ7ӂUz{(b4tn9kl}*I?W ,A51q?߾KOvZYhм?eg~߿/~/_}__}n_|}wxcW~o>1'ڿ}?9~OO_~~o??v)|?-y73} pV~dQc˯qϿ;zlw06RBoDv'=j{VNd(?Pܱ̬Sg#. Re6vWwHiL_M}H,[.jq)r$PpO'8+$/6(en{u=L66ɶyÇr-#L16ޞ&EEWXiU@ +>U.X&w%BX@\tzʹFe]ŭ\P{}y@ z{(Vv*i,gpt+NVOciu3Oj_" c']'ګns?i:9 d|JG%, .dy4C, +,[fLQs糧7vTjk3,L9yɋġ7ѭ8:e=*+q]])6B0 5" k' 2J$$0\Z]KXk~Vx#qG7QQ k] Ѷkbu8.6Achޘ-=QKDz0|H_}k / vgn~-!@%0y|SCgLenŴjB +BЃDE\̀~)galP9^͐b1 X@ˢ m , ʷTczoAȶ*~8g@D.f'rp D&&u⨤45,pWv&rNd`խJn;sDҍ*zJKH0)Eƻ |7P׿o,axK>*>Ov!10sJڊ)?J66*rZ +oھH\U|JxC"QS@'*b'JiRJUx $6wlC+O09ݓP>觨̳Q RMJ6L: g)+'AUWX:b&j"LJyx"L>ijK. +eYlv:SO=g-_,N eq ~g?ſs?waE_}7__K<Ԏ?Ԟ[x܏G5Y+D6</P2C[1,Ax`XwضWOg;<1%bJFBAٳV[Z s*jŮֲsԽ#G6f "8&36xz;'],n> qÍĢZD1mklm Hm,r+\+)Kix\hHw?v8"Gz^{M i [UQ 5-6y{t-,#lC)Գ|ۏ Xօ>$(*Pħ(ru;E]u%fl߬l,A,$R=n@6$zQ f]SO=C2 ts. CAfV$ِav ='9ORkNE"5]v4, 7Z{T ,EEiyRxdDQ3hC+rٮIj[ՖFLWvY7*6~C3Z(Iw[E_:~'U*k0 OqBi=]fb̨[3኉! >aXIlٲSW]>cdAsm B@YfHctz(Z&v݋'&;-|#&r[e#oT &eɒWFf @È +w\D$<NA+Ҥ $D@;.D 2ԩ>rBoIcl=@H0Gkk5F: +Z$t z;k&dgGlm+Y֛>~RE*5Ԏ*/<\pv`XתX%&Jh"l_Voˑ#"vACxc!=$gĶud3αBm7V6u|uqp$"ixAP6+9QC8҈g?D?8_PDLȠV)>0ʡgz̈`6qQXQҮeR֛RN)jyYtYaF^LԓeՐϊ ?b2ǂ[PW$d`s.(Q"̧UD[A .Zzj{A6~@O[N mLFE_.#g"=e,*wy +YIU# m_@#x^8%;5eykg/3zG;Ō(qԁz i6Y;ɣ(Nאޣ)w6E*EL _b/qP`GekCwhFg.1)#{ +"NTLW$X(B";n#SS1N+ՌlYw{kEs[>l3ρlS(>6vRy^_sh <\>+ГSR($ۚEK?{3ߡD)XL +kKŏS߯H2n;.L5Ɓc#]ȽN<+˴^|M-ZrwAnΌOIf}}LRF +ۤ0Ʋ#s1̺i^bAPItxH,9+ush[|F +%yiqJ~`2Kh)蕸 +ČjzñR؆sl"C%4,Yݮ Rt񐊼J:4?WM!fP%KL`Y*r'C%ޒYݨĂsYk,I0 Pqfn\k~œZijzӦ^k~u+C5.`F#f15_5eAb|L,4-lGEBA'51UOR\))Q]EB.RVRcao-j?@yP˦ ~Rդ9f:~8ptCBbm IѠmŇF!YXi$߼2ح-eO )VJsyV" ZF@N/+k@%eQF϶ў<%/tiUntE. un6!-qyYbK4d$V\09¾<Jv?@Ԅ8rAjĬMYE \YBK +ɂw +o+q{;ʈvug_zg¼ lqzyM5H& c2 SUB խ<#X(^M!zH M~uxA-dôρ:]yg7&2%Jڧ+_\9ŠL'E+_k@ n(1>52K2oث{wh d} 0ޒ54,߶"IZvQdkFr/ ~tId@@>T)2 Ȣ1+Y}Ě˱8c] +asI1 +Z~Z @L>Z(ʺK+DerMYDLCעsͽ% bm侷 kOYED.`(l% x QZm.e8eb 8z[7%3k$H tDάc@׊ >QVOVFcl Ð9ɂ&׷Wv +)Jyi)V<g+zJ"Lx]Kϕ+;(rV LRnkWPM{-ubNx6\7S޶O7*+Ժ")/m ?,de8\ուH&?Cjt #B,Dk)_(r.V{#@ J,ME5[} ic^g_ih7U크T\ByدY;&v92}|5l}NikF,GaKepq-ESN oxV 9&WF׶.ݲ$g(_4Bw?hݘܵwCRq>٭1M̼S]Q{~Jݯm6lmmYٺַޖ_-~nmzۊAnw Qn@;al];nC'w^<;\c7>l@0dÙlDhOf6̈6mav8r|fA;@ь{76N{➶}CK%j#6֖l ۜfÈm/}]ZҽBwoﶤu7yͨ%[ƾ o 渟eEHn5iyWptl έta@oK0t+lӭtWHuAm ٭alo`Dwmޭ"*{xnYP w =zNmuEko ʸ(!g)yoE TP[|oE o[[ICnm&_3akllĆƋ+;bmO%Er佭U_Mt̯NznQ1ܤkU)-tyv,|@գᦁiЍ!WoUOvbHjĐg{KG,d֢?2L3>ew9QYlΡze\~iSa !V}j-]UznZ7=/ooڲoz|},7_n]~Zk8MgnLZSng=jt&hG.D@Փe@@;7Yd S@EѲ= yTYǏ0lX!!w&%Ѓ" +F\} F71Sa]l߃d@ffuwoh/sO3j2f>g!10<0Eʲ&cVes‘`;qQe dQ[ݰOIq0G=u ȷ+6*0+QOE">w#"q7Q&k{$(z!R1+|Tja*!+.c≷gjhל&`r=:U(ZK :߸TUඍ|̉s&(Md!t zl h *{2ZZݔ+X ?wOQE <@^)F*%{M)m#r*|Z޸BՕ!@rO{ph<])βv䀑.<"(Q뀣-{!Q6pC:Gp<rK$20֧w.oٞ{oƶ%&7ڦjGY-Z0(}|a6%om:XT,`[_..,l25৷kıy%h/^V `ڂȿMTT=>rYۋm -s>}YQ͂}[gӾi3r|om_ߏ-X_G߽{XG4A,-R`9{l ?PKnԷ?G+yܚ!$u7M(]PI߼#߅ˍӽoW)D0 2 U&BHP F!;W*lskwd!n `1weϼR %P\-89/>yMvNL 47^*+92EVFd A\ endstream endobj 108 0 obj <>stream +OTQeV{EdmÈӥHm:AX5  pRꋶ3UЄ3Ⴟ[lT~r.MSWWEXMa чwl N∎^_0>:Fen2fmjY絓ӕ2%wx`݄bwh¹6WePq};>W?NiXɵ:wT;F~?b^vrI)B(ӫX?PF#F'O +mz M&J#(43^sG}i;99֏%@JO8ǾxB[>G_$9߀Aj0qZ4luG{k\ ˛l_ ,2Dzi4pShf p128/Ei + aRQ:!'CjPhl9XpB'ZvpߓllW`KR''F>>jP߱U&c|ARDZ>!tsﳎިCAAjVsF$ɱ[-Kկ? nLf>G;`w^%mZФMMZz)~2|W tޙ7l?ײi]){6]תۧymlݼu;/tB "g{~l{4j% 8SByPN6vnYNI;`%`NmҖRHFH>.;?WF"6~slW;a/ 6#l*{O! Kξ 44OHa3mtfnhX2*'6ӕIS;RԶU(6Ҭ "MKTXF5?4p `72IY_uoD Z;qܢ*=Kj'b%"-;ʄW$ +s{.?;Ю8-0۟wĒx^7;\m3^g߉2 of+dDa7?'NJٔAvN6੔v,a1󬱾8nv24ųvxjɶ6~eRwcf\OoqXn{3P$Z2p(яHl-%wKKyJVN`595#͡FByߚ|޳Xf&Kk:j/-=$.kg_Zcޘ GtI7f b67f=m<З -v1ƌ!&Iz4KcFfNz1E3c{c[&rAg3rD6e~3zW:A5f>jэ֞!m"ovINsߕy$MO9GԗbE9?#G̟ 1m $͘wmBw '6 +j`p.ֶ\R֋&k8$C&8-%NculXse: ,/⤜Σ  6܇B%BH`©l͆;偦-yYYVwԮvOZ +*%zm_qp)R\%6J( K%Nk1ٲX{->^mH:@z!( \/ +b$ 6=B;zBUУSK[pp-`BYbΓ!d4=&-~emS +ҳ6۰XMdz\hH# )L +϶Lig9Xv M6I*e|Xs%]d? X4/-!nʡ{ˌ8 ]~K(Pdͼ(kqo=ҒEc$8,dީ>,4>6Uɕ*\Ls.!=s v6>h-=ӹȺIcqxRfCj;ZCϝ H{T<3'QFuv?dbĎIچ'Dn;Bsk+HϕCJP^"#q@}!\,J\:GZK "–aQ4`U>g՜$D *l8ڧ^7=!袙Re+R-Kb(`ϴ`Uqۅ./^xЪ /)JEJ3x-:"s(1^? mJ1++Ӓ"b{Wd[)4w[s X X4jySyMQR}@ط?aӚ%heP|ju Łs)h\हM6f;l6VKD.b+۶a^CsJЪ㩾օy/״~8&TB΂t`_x%-= p@ut(8DGBB|Ot%< 62Ű_=&&z|`1lΰ{Wx^d5OK +-jM ~5y)\71 XFhS~\I]k|(J:,&J?1b=ƾ:´)x>vhy>{t;}ɋZܽkSp_vSUf`nRv Y.t8]n_LdN8C^Ñ)J߈aBhG~x1=J/q9 6+#[m+# au PBBB:~p_ڧߟc AM#8YW.RŦR_Y8S:(V!btgTPe͕j{G7z Rn?-kjWrLM P.uKP;A]Q:3s# a13SUafd2\Mz98U2%__= ^/ܻPV%.XMe Rzڅ簽4]UP_ JXfy~VPb]Tb+xPW9µpR|@o A7z)IHwVdpXE;#eK7'4j|$@뻒'v f&|ɼ;=[ۓ{]O֌d<݂<2­tӘ75Vͨ}iO]|îz5^{Ѭm[{+u}hHͥ`ƭ&Xc6EmbhczPmW]+%6V[nps}=:}o1MnSnw2ys3zsYNoo$71gj v2ܶMqڟ``\CMq Dn 8fBHyNw[`n&z[xs%ܤAoFHތZJg@|s` oR(|S\Uup'ZcgnYʭzl*[z)鎜Nw_6%©=e[2:GҺ)$V_y—(gʻHEG}ӘB=,lأ"= + +,LWN*y 4ʍou[,B&>>w@)B&򾕟n&:?]S~݊ϊڦVwտVl8[^loHoMfmW}ĝ>j[Ay-[늭f+oWEۨάuew5fاڢf[ckkdkcY2ۛ6twݿK7Z9Ӿ,%k|mK4Vͭvnmޚ}Z?[[Xvى;޶-uGݺ՚]쭙m[/6XZ[ugpoCED[|Pk6[жugn-6TcX[ ʰ+ +b7]!0|o!OVȞr ɞ9j_ў Tz8YQ{Zn+J**+lIJڞ 4u6^Xۂ҆Q+o+d:0\i2q6<]nCCJ߆j+򶲸B7Jf Ngu"Srg}n حg-(oݭg{ ֽUOL*^;@@ dwɖ.} +Xc~l1RW`{@K(ZX:k.1i1"tm[$VrgڻB +$ᜭSֆq8v ԫZzX~evyE[,u^ HZX&6Y}e9V\m-Ψbܟܟ]t*kumWM:gxI֮\TBZxOq=fϖԗ +؃ ~^y Q;Qƽ =%ĺN<9ȝт>Q;M"}5VE97d;vSWw2n+㈽o +۱S owqcZM"R n4_aoR#eߝg޹_<W|]VےiʹzP0QZ mDuOkj21Őw?e=xuY:9d",vہg)ӏ9dpm88L1|ߵ' >?އ{޹As>RM JWhcnj_mJ[-7"U[A\ɶy +SVyoUmf nQQWbwHĆU5_E.Jwwc ob`c_pP^\ηZŻ}wn7v*kzPIVQ8hm UwawRWE7 +kK ݵPf%[>Uo-AL`n7on "~Fjor٤LEfVZ >[ $Mp=`.[u'{n ߼]8oӯDe5:h9 jUB~;%ǯ \EqBtSa_ѝIcWm6vKvSa{WS3g+ b[,$Eк^_-W-ۯ+u{}nLh{ۖv;;qcι;Nms^-@^޾[:K`cDӋJp;Faݼ!w-hKm\Ba-թ|LZ5௜h&\*w$Eo$һ?K6<}U'ai8Wʛ-bNo*UywZ]$R3NzJ>WݸS^4K_@lwd[[֛Lf.lۣmvlk,m:fOӽp| {$WcQ[lrcTj9->En7O#qTgRN[ol>t4b()@t[m*dxTHYTO)`Z=|6%٤{ycT7ж~gT"t(WleQ"ZfېKm*Lux}fD\nɭiP-7%$l7H7>JuJՖi9)B,Ű[*Pr̍zvէ7mJؒyN$7s%%.7JOeJ5̓,o% {+kC 8;JvutqsS=Oij]Y+IǏ%48Gu踶[+XA-M!Z~?$eeR~//wX_{Sgտ}b8nV#RI<%GOՄVD[b,X);. pњ(ބ\t;a|H[| Y_] Z쭴PYmX,f]wwh @GTc `EN E^* Wuז|z@e3mu݂,F}XbT޾7""^1csK%^j\:s 3xΦؑ&Dk yyw'w I)JPʦ wS|R8Tc@wjBT7CVC7[*J< 83&JT% `E`'yl. FUuyPhU|u]3c$QMFEUh%$58 4)a?Σea*xKԽ!Sx)B:TKo];[2]pt2kr`&?@JzpvEk ('y8a鈡)~WU8) |P(Y3>ePLIU5->nЛ(8)%m:LlӠDe +TOE OUI=@0DSknւ2ߣjGz""߿< Rh4s4t$B:\3abzfezW fN^ +)NNUBޖ;w ue6lXy>g#ɵv?BB!?Gj63BccF;Kp4MgMJ\xv:@ٙɽOhQ1˒ +7EQ0{dЧ0W}HcW/e[V@Ս˫}F(6n\arQ !wa10X%i@d(= Q!Z +fJe +vc\*\ )mMٶ*M('ZJ:J!B% Sb'YD;eY;i1a%+cW>ӆ 6Qu)cjpe@HN#fZ@\4mmkrrсG1rU#tpy@>Iy'XHG&KwS'J7dѾ +91~踥0UӔ>ᖸ%$FR%`2r#MkV5W P3ΆIZn=E Ҕ Η +z~lU AW QW Ma^S^vmMGI +ZXpm:(ذmq@ᇳR8ylօr-omlMK'(1JՙO XPljsITfa[}{{G]4! \95`IzsHϼyBմ39JJLkTa#צuvʱ dڎ.cjFUR&&`z>B-%7T auZMo(bP([$_MR{k_6{m!Pth W]tyE +,E +Q허ۛ +BKtIkR/PdL쳴,31 +I1;D!#܌zB{N5h;!LKdnf4Um;J>hAнf:=ɤsiS1νmD +8@V TXDjW Ze5c'ʄ@C+Sw%;)if¿TUW-v灍P%[%)(m4;):BͣEsEoza8[Lk-#V v]R_~vJ*/seW'{I!k;F'j&aҔ|a%$,3L;sCͿm}" +X,r P)֥ 9兔X)dY6DCh+| +r.zwPS76@'z7N/4ǶLo qZʹNƒ#_$J${ qS#Xۋ;XiEP^)dtJj_;%,>\TADa&iXC?AfHqOFbp+"s 49 -DӅQ1`QnM'It-ߒf8vgMPGgTL/DKBW_)9HeoiR>>C"Ϳeuulk$InxFU遞օnY+䯻E0%%ě]y*|udRgz(q ݖmF^5ތ֋囙 )7XR"$دx0 +u26K76fRk#YE 1*Z;sZ1f&h//"`MQBA'PA*OȽ֡{I$Sv0o PqcW=lbďl0IyvIfwvMLt_<;$(~ +`;wE$=nzZ A5Ae=tD:a[Z"{z8 +>MdBV?yX xmaY=X .}Bm  +$ +8qly-~26S Tv10C/=Z7B3C (ƏoYvF _Mg^Ngҟj L }U:v8-)̊ |J='%iZ6If0s4wY|fHuQ9ħ6V'(|y[$Hgؐ#,МPXg.gf/ 3Q`]yB0Io3} !z^\8jbgE'?ev5"mGT!3u^f0;s=}IK,ܴ>0)7og!I!Gyqy[5as}u,& K:Swhoc{Fj, b_g7x9S?g͐ht4p*ث5ѢPʤY5X۬r>6&eڮ#\|Mç LzY."#Un|6,PDA0뤚}-ڋe>0Slb .g}Ck`YyϧɌ6 .&v PΚ 5TMK6Orj΀{jpq['q(=*SnLiIA4b7%[55wM?mte$ +wl]}: J w]7p W{ℋa(W +J:WMيZWD+Wt:WUtrxmն+sY"4lB%j-6Ct,atft]%3]E3.lEw[?::n}WZP:ya7ZYhʵJy>:;J;z➻o/CoYk]oBޅK\! m5-XHwk. +:`kp0df s .GvBwwe QmӵҸ™ٻϮ?#;X.چ]L}N.ڥArvak]6\k\&.{Fu̹ۦ^\&%{ w:ƞ1[RP+4sM|K K 0fҵɰu 5fl4@H\#CBnÆC6g +;"I_^ӌ3Qd+%AP Ozֳm]M2ys5!9T+>y@ 4ښ8 +Vm=kُeD OY~]zhuT?&ww`];ܵYK'[/f"+A@;,OD߼?7cI5qNQ gsحeb=Tso<\! +2&;pl 2aP)eh@.ᔍ<Hi'뎼)Ǎ(ɉ69V Oe]Y(4%P,Cӭ!Ŷـ{QpuTl/ !|L=;O"G%<$I#qGEm$I'oGIL =*RqlaӀ|ŝ"%BUD>p*F":IItZz\a= CYv"N{Aۅjd?#yŸD\Uw~\(gWOuwJϰv޵N܌ +vVy +0)'R*"0(Zpĕ48\pΗ h3Kh+@ˍCs4 +{-:Lc^( wTVfuk1+ț$KxJ[toJ28+Bᷞx V"ՀGX튒#ޫ81ZZ=gR6Sb2嫁?-N8P7WM|/} ^ޓTGHIxBaT1؛cC +`j` u*hZ%uɕx)x!l[a{>4CS{Hꂪ)8IPE\ФBx**zY iI  Ҭaj];;]AUfmTEZ}"6YP*d [ j+d$dQpHcz^"$mJ8;B/zhx=-g@@!;eѷ;J6%`h? cN^BUö FS*rL +a:>"o m3PّpvEZ +w^aYC[X'%  ,ב\dǂt"KGsބ^s&FJ2#5[Ϙ) <} M[0lSQ +ρ,֙#vcEC VDs)P}eO%̀5Ƚ9.FtpD[.”D"5Y2XɊxTIZun*ΒBc&<ӋzR.q%џL2r"`QunőȏKA(PUQpFא}*[rf.^=$=;a(ѷ6c?Dfȫh{kGL&V2C&(ܬxTQ#2jIm}D#O tr=q p|*;ASQ".PڜDFׄyi.Bu Rٵ o:{4:@, a厉=Q"Mi),A4=<3#tM0XcχEu5a`SB gxZgņO?tB✴$__zgJ J86 Mw>nrT#3HYW,&|>Ə"BTX&²%/G܏T=  fRQ֏1\`P%. b7t)g t1mչlW9ă" +4Jր(K;(ܾY& s&3cgs҄15"  -"C32#謺"~*N^c+xCh 8D"MV ׶j`MVvpr7J4HX;coM%iq@XТ*X"cR=>pPrI3'HkJTü +-U +%5Yp} kV?Ի6Br88jĊ{Q,#T%x3l gCwV3,]Ts=B,ж;2ĠqZD)Lшgi6UxN@*9z ;vM(,dg7l,a!Y’'w_T,EI̞%kH<^¥K vMe[;:;ԾDϑ"(tS[":ԎdGn-ɴ=ԵAK\И̡fYyyD/L?G}}i&S5lcxԗ=TNl> ްZс&/kw/Q+N|^ \R>4OX:@Rj4eʩ5 +0ٳ Reb@jhjv )"!4WuC +?A^G~?9*Th+&@( _(,N/UJANբYE]Ȩ.E:] Vz}-uT_¹tMBl$U#݇ W jLdj)鹘kȀ@؃rNWtd?h3B*0dm*(+l3d>MAB +O~U'Amo +GNx?ٕWTe`#)^ t( Epb{ 0LK53F4AMzjј{sbtVTX< P! +[} paz7b@k ќh mdwLeo{Re:#Mwwreؔ.vwoj{DʼnrP7٦ӣ['[o R#F%f%nSb(JlYQ&!@o@TV.&j@\+dN%ht6H~gu圜̣U0F ;(awĎp@M75 LoT"n T^]>Q +@W T1a]QlV @+~ٯQ)a>^+@J* ]Pڧ,2hD YA6g7Lpr@=3%j+R@4lL ⬨@=o +OU|/\Jp. WUJ5b`쩋qȆviB(WE{7__BAbn֐ʅY+8_vۘ?\VHb!{s0N\`$TP`p;}0A=䅬1 4ګ\, +ԙ1#lJP|XUaxte( #y*xkuGJ1< e>!SMnP0wb^^! &z(_%aVduZEl%⬖_@U7Qm"!a{ tv^{f.6=цbloO=)#NxYi _)€TOsV zP܈(uJ-mD74-h`'WI.v3fqP9I +1p D`1|VLjO7Ǟ ~fU#koEWfwl&C‘x*( Nzom| P,b0wN*LHK^0S%4)*PISZUBN%aM$A;l1F`P0pxߠ.D)NZ`0{|7S1u6,q&|O1w$;AO%!!>8*KPadI_vpA1>J+I"&qP܎#A Ka\/uV-z]4Ezg3bzcVScS\"<2.,g (& IObt:7ymTeA% IU` ;a|WO}ҸB[el;ïÀȞQgb,S#i*~zُO3?*yi胯'RJ JBj0.`Vt>\pqX 0`=# Ii&n,cSzdMAS#'JE S:H1ڙQv N]L8H! aw.K$acbfM!T)t~2 XF %"S;\Xm]`tGl9h(Ei0?}q&MoE@BbOE}Be&/`?PHE!̴[u<,_V@\'@jD&I^36xŦGÝb#cz +6{Rvbns!L>MN" +(%=ŔqF^>`Q!#g~RHHJz +=&28Oc!&9ф*-פO=,!I>%IڪK/ݲډشMq2OTJнH/ [TwBPg'!d`vZӡxZMt2xvJGB'Bw( g6%1 +6/A9yLR~:'7R΅@k!p|#UmjC=j]nS bhp +50b~(/ ;Ӊ(( +t^up@+.>\~(ʲkkФlAr?phYhCt@3(/:KffQ*"D`&HvQ8gl:+6ISɱY'&1QϑU\7_/R/'wOySJV>H>]`v+ePH_tq6HJ؉w)qD!#zdɶDeKH""7 ?7!BU^ +f"qwĢd,4` +$q;S#GNQ"҈3y!ʡAZS= +jHžxS&W&'/M] !]B;O#0sQQW}۴+C#ԀDR@ )TeQH]YEab[Dv 7Q(6$x!Q*~4- LZ{.:#Ekt֛,$/o<_1+SlWq4n;KV"qA@;ar <% Sśѱ #Cdo+w)4L$f/dX7~a3Lu2p:G“J i=^/#UsTVT72LP80sSDA94 kwfJmH_ظ2N%*,>牍+r.9 +ePm s*AMT@4YԷ`1g<ԕzfQ[JxSO>VzױC {D#pP\\HQxTxoK MjSM߇adz u8aV2_m1#V$vl0.!GzApeL7н%9T}!U${R_3g,l\P׵i$|<5&K*EQ㲔,rsa]sA#RKatl,$!ft,4Q#*1N]FQF}èbgcEq<<iEF1gxj¸%@"6W_l{bi[NGmݼfHE GcLţf8FPB)Yg*), +3q8qu;Y $AѸvv}d]]a02L]?cԹler$[eXJܣLWMjKebzD6B/q0Ko]gJ8 _e~ON׎ OfUUk7I |6% & ].Xxk߄.NJe +QP"C,+c^Zf- `Koem+ +b"91L( +jٛƵ^^E.Lt 5$94ret^eD}1.56ӴFp #",`J} z@l;AuўAA#kyw')Y8ΟOn9 Bsr=d\djG \;j0ΙPR,|; jRG\0^\'Ԧav3FH@~ vQci +Oq't(VIVAy<<O/KThjO"`RO'[z̑eg} +%%jrp$:C7wt>ٱOώ}z| %t bEZ_e=Uz +d";.XFsx9> ڢBh|:eK +t2M^>ϑbjj0Q GwKkֵhl(=UHdl>zh/)I=cOy^C#B܇fE&e|CV0Pa8K>C[CvSzL DA92?:TگaPҝޛ5H^Υa^y,滴[f>C+BEp$K +Naz)>R PkFGfٙKGܴN"Fzc|w&ZH5/ +0> MUGR(ͯ \Z62I 7#_?ܚ' >Tb`Twh"_)%Z19U-,q:ʼn*W#Zi`g +]WĴڊhUvYU[rj*=Wt+]W+$s6G{mBr6g8gŸҺ54+1AjZZ8BWɵ٥wEμu5LƩLgdNgН +LTָn]W+\!Wf)_ǻsSw +ꎷ_^V_;yrn"֐{ߕP+c߆4\9#wAWb;!`\?.PaD\36* oXqUQ@cF]6"4kqQr::v\duX}GJk,w=bھgj+Vw[ks}If|G.߳f,|sܚMvo[0o]F(~UrO-%ZXp'$gdimg' [aկ2Pp=&D)LYKA)2fc%v]>q]=f͈8̦H-jh/,F|.5+fGAGg#~[&t˚#Y~Fkveքk\}1n[ִ5s]ε]-+kMokY ̩ jNuglj= %W$ x5t&HӦDYӈyԟF:4@lx?l4?HwQl>Rz뺹wx#P$l86ARr(:d$e[}H1hj`{[FXL_`T< e!*!Oh'Deqo Hwg| A +`ٱS5G\ܭHGbVs̱>@jJOoPn~p}bؠ"=qÞP DV5?f`6Q .^+SmKS2#D`SLoV<,p1iEL2:LzNvtR~D7 Eq>Şlʹ%f%xMDP*f mEg-ifUnK`M,^ejʼn':3#|DjJw2wReg)GD.u$v~.k(Bzk ;+Q] % NdpN軵tc'r[jy(V=RNzR^g +waיn3rV􎭅gL2 +, ίCP: ɇ& +{$zo;NtH}RwH[Թb;N:Wp N_q:kN?q:_78Ht@o1;@!o:;tiAo:=ϐAo;tAo;\`犱عB므Yv:d-`CwYt9>FO4,fIg أ`2RbӖJ@ +FqwL +߭ uøf]gإG} =fg)w:]ʈviA{¹HƕrA4wel];̤VHczܐ2 $ǹ{ Ë0D箸͈WKs]MPVPgnZP+mgsO_ZIx('UɎׄLU9:6ޔ{BFNx>tjJs|oy񋟒w(ψ/]'Z^x0V&Y#d+Vި +H ac H-p'6禭Y?eou{*oҎEϞZvr[uF[*~-˃Pu,P.w|Qr|Qr|Qr|Q)(UsRҩ9G)9՜gܩRrUs+rRrSw\r;)vSrvSrvRr<<H1OO=Z2ܚKh 琣}9ٽM{Ux7Ҟ.M%+ HQ?Gtݾ(iW:C#JǛ?_{G@x4Ը He(Xi׳_.sՃ)}L֤r=$S:$r 4/ fUlSHE&RGp:W4Pt>KMPAYLr`&Ux&wҸ7de !VֳӤͨ!-C8$FIB$mI."[Yz{J+:^ӸeQNEt裆L,KH&mqK}dj dm 'zȯ԰Tp5n,xhuK\]WU.t}#GUbI!R}6U]GtXp7N~GOЍunN&y3tj~jBRon^tZ"ˏENe73}b{^%/25 'ji q{łicb>P if9,ZOG:Eiȩ)˘1v8eG A1,_bS.@ ˼r_ww;QINZmI=Nr%5;^=1SeZ\ =V!@v,=Kk7/|-Ⱦr8\@Fj DFQxB݇%0""3R= :e7 ("CrhOtWG̩1EzEVj!ǐ6yA-eDMP{*ۢRLQU`&V#4 G +L"fLERFVHMq7iV; RP!<[OsckM $d#%x,Idm$h/2NX;[]Y/Q[dzR[h5Ie͜6h +>ZGeצ(I5 YzD_f4"hHzOQܼwu~$۩B(osSwz>G| f\;@əoCjUsGO1I")ڗ)4mcb`_ߢuvzނc'g`@\+^3j@p7_aOyI˿ӧ~Wsgwg_=+uX\iT%C$gl_D>,àԄzI#J`)V7!&qeIC6&?F&,{dH3N399q2'U;F,-#例knTDdQ!~_&.); ~fN JlHf#YF UGaMS>%Dr&;SZuuTe(Cl6U#Dؿ @R3 ΅`p3H4[R0`hLCјVe)L,\_[L.>V3bڼAeTa[ xtBTSwC{|v:YRj}alS9NN^dxsz;*&!i([g*ZSɢ9E^7ء&IhǾ1Mn(WMx !25ښT:V8&>Qܙ_DdBK`BROPyBBf:AX3WnR-4 LrvzgOt 7Ӌߑ~#upS }n + +eBIb99hLs&1J53fJRչU"5 +aie@܁ф^$D1vr8xl >Q[BQETvֻB_GC1{X9.Ou6!s]];#3c (-QŤW<qQBvZ]>N$ цzƼ2Խ +-6ggmn3?;~t(uh +ԚDž9 ,氇*'E@rX̺B&s"]B!yTK}EîqvƅRadTd&GcX s@&KA[N0VSP}wYRM:SYSIŸ@q9IZk(<_Pu y\R&zAQ! IȮrg %Wt}Cפ8.y_<*g50W/Lج;yc@ ,\U9TyCTMOOAc Z`6!~,W jU 4o rͫmyIəB 69A)0gge\݊8j^; IiۥG> +- 'Δ{b/fƍ l賭7{s%m9d;y`Һg (fU5p:1 SLr$= p$zv N:78 >}tO}ЯC^oPA"Ĺ ޯ֧nDsE n +~bǖ!t(Y}oYRX#`f$p >EP\v8nXWPaKckb%4b)dF4!5"B)jTYým#Դ',!f/9Ct2űnX?},r97+=<96s PN!.0g,tyes \a7hAAϛCwCASo4/RF儭@n)QQ-x(^FZn>t0m4 oY$6jʰm7xG +0ːjG? o\ R@X$>ޔhRyq- )kݟS0Mm#yvj!TG7sglk(9o%&Ls0OT.S@O h{ k[-shة9Ǧ8( n+j:̀pA`SHy:F5; Խ9~]qy+ۯ;J}Y ) U9w3'$`w궓,o2gQD S:au{auq-4cPMdħaY>ۛ,;[ )>=AD^x7 "=pz$4>^տ&k^3\0TkM0e[oV3oQz!XʩR}Ԡ5xwE~z=5hMi߯5}ugMN3`MM{oc+ # P3qW;`<`@t΁ <SJUdhۋJMmĥn}?8yӫ[p ?͗t䬇NH6dsT `2eU-n8Xy UԸQߍKA\?7go_`0VP,!T2y{_Μӧ=Dpk_f"7 KB5ag!ZBLTKD"vY.00)Їc'VyP~$): MA=сDێC!̖L + ~ `Q r,JX K u`WvA P}9x`Oǵѿ#L;;%Ӱ?oN4;~^@'#ݭYzLɧ? z{"`1a`RL_tyoQ՛n帲#!@,z84/\[u)i ,){+FPB"xg=' 3S $&lfߍzO5M\y;Q-1^"+v`C Jtx<+EPbO#P=08t1e;iO2G dKi,%hV'1*DR8zb +A'PcmІ4@kafL88t_d1yJ:)xzwz)HYzYtxELJSE4i zط +R2<%j4ىFiU>3|&;M5t[/ѳ#,bGaISc#="LH5ѮJOAq:JwpiW/vW_fH$RV 6Uq^W'wb˩:xCIE.]* =B'^PRĜht]L4; }c0H~K$T}B;Y<ҦTA0Hyb2..HubhqQ[BFyBѪXo MLE^T;[AJj<[:`(Xk\GO"SNx۝cTIGJC +n`Nf5dhIDgS#{wie#EqBggeWWYHScz 7|Tۉ8D9&!OQ _wU)u1M75<`^&7Np%R;ja@}۰ERJd1'*ec'FeTaҝCQظTjЉw\Sf_#*^ `;^XV Eu!"MKMmGa@6y݅փ%O?PxinzOe.MJ/ +ER\%:"nB1Obz j*&{Dm̙QRLif&z;ڹO73)KP݃!2h|Τz + fBst]ݤN-SV$T:3M b9Hyu`kcAFde bV$v&uF m +Q$G@WR%4+ɤ1zo$킷YMFފL=E3BzvNUYX + =61 +^}=&q%TNpӈ%8ͬ)XR.T ;pYx$(l/0+;"~UZ bwrt*qgwUua͝Q={fSr19 +{e& +"v&_=-*-J@Qޘ_SHmpjTcoS([̛}ܞOkԾzr$)(7-^KO`7'W:݋)V:NC;^Ư]]17tO4__I>x/x_~ӿ0uo7zͧ?f|Wf[}Y K;w?|O_?I&owco/_,mY^-{//m7oߎ3XYP??{s7K~/>^~/7-__g_= ի>cD1Llbseh:PNf )s|E f5zVOŜQoBU=_P}9aT"XA*?60^pl)/}R8F_[j*{W핖<`o/uCXCfƗS7m k0gz61 l 5?4sh*x2yů$ǔ9&Ҁ,w]NyLF>?LJDGP=-|N!Ya_f;g|1'@и?6>o Pu{t}lDž8H0*H7͸i;iwGs-M^x!Kd9qe`V1uvk@9Ɠ:1 +qJg#x܃!hγ<ċfz\r1u/19qYXs_?LI&gze|/hC|{tE0ъ4%b8+st+Na:4ߍ8` gKiTalc>{zs/BRsOTëu,nZDlsq"Y/ln;T9Kz1b{9M8o c ])nc +w>z]c> + {2]UYs5cK1c:3[(-LqxofrߍOi-Sƍ9nV'1Ep@->a7_ORZ^3sd-8s C4%jS2Le^$s_=NK`=xR} JwFqT1>cLS՞V#xwhm ʼWÇ|39OײϵQ{/ЅHFC$gYUC-w-Y?k׏evh]|i(y u~H7V5>lk D_ *Ѓq KqóWV޷ +5&2M'4b ߍs Ch$ WA_S6n 0S_x8\^B:Cǰ'-Z*-Ϸ:L-W[ t[r*2PLӖ?c- p=gǙ'A"h-펹\@)[J$ոE yK߃=9n5'Pz,aqqp݇<[-rj}r nu^%sճZJHD C^zg==i~XfW6 5eѦ8WqaWfܔ0:!;,t/©L# A:lTeW27OO|RuľFJɛ=Rm_= ╃sN(tAm'__2.>N3Шsj| и@O$Gw@ vfKJQZ몮rDY]`G>h4#ãjĂ-v}"##<.Ӄѱ +2\z9/5aLO+a;"+H})H +QUDN\ +ѡG+REпO+䣎~"mX +鷉0! DiSd#'(!5rDca:!j'(գ>O!Ied1"-_E@JϠش)pvb`/?6;rSY'gVF=SYioHXU3:U(ߓ$z,WTzjN5=5g-lU}^ ʸQM׏b5+"L(FTX;EUSfjy{e ybSԋ&`!P阈$.SFCbHtR#$b`JI6ih IR[>Sؕm=Wm.ʨz"U)Х`H`# JRDas NKhJL/Ӕ( +,}?!e?򲤊w?呧S~5wK]Hrs#&21){ɜs|zxLj2Ӥ!,!(̨BбUi.͋reAAް)xP*HiHV,MCI)͵Q^-<䰩j}܉D405B,ۆ[h*/` RSg +*G]Ef̥ӥ]r []!B8μf O|; R2 6Oy12PqI{I"W"I84ԓI;*%DeP*\= ߟ))h^_(i)#DYH +>AhȜ\74/WyIDa0Kk ."HAjyDw(/qTZ+,iҠnZ!yJUeZenDz҂/I pM9U2UU͍^B=C')|Tc"pH0ʖ@BFPkO>:ٱSeC6vi,U({5( OXjK{ +PQpQXdYx_7,7 yN4\b/A)x8oiJ~{|Т!]q8I)&S/k$ѿW)ubgokK)=뚎~&'#TOڜ9$,<=EvU򂧡 >˴ذRxp) q$O]{Vp=-4d3G 2 -0BIjYЂ1-V*)?$E1&7)Y0VgMQx.;,XW}rq{T IӨ%+=:n((8DxiLJ\KdѨ'k*S"EF5I{:رx 856%h 4 kd+̀V4X*[z\U'QkbKC+T+8xyEK$֕o;%pDK؛a;<31P2W.l)ˢ䀧`65 +QC1(*iZYУB.\FG*%W(ǎ>=UK0aC[SRI&W'Wq :Q،*l(z*.BOhH\klTgێ~nr[]ٌ"\W!e +hu@V*jm)Դd TDCcj+65>h[} d|HJץw"-PMYq|S$cv~t)ǝU5w5!hݍlHL(mcG ])0JxҬq4T.yt4y1M$cpU>]FrV^'z)`KIUk="]Fz 6[2bL%v`\ץ NA(aX֕=t%-},K3lL2(I]1x_.~tt9PQnsÊ&6u)83H# + pZ(i=2e ud9[rښ]m+pUP(nC'"Q +^ML\Om7pTH`ʄt?Jb[n(iLfڥHMLDIiS[R^Й\젔bS:Et{~ly4" +ؑ2~lKbA%{ I__OF+@4t.!Q3<e,{>i9*b &w'%zJ41\W~-p]LKPTx:QNB/`qE Tc/xGlb_ fķl]+o70:R-(J|*8NR>iY:(TqL-ץ%6UquMFHWF8:@&Δ(8 ؎IF8ia]*U`(5:H%UyZRO&=TSweJ[TG x147Yss7-OIκ]OX46pU"\)ϖ:kQWkpBGdF)r{J3d-:ih`QAvS/[WZ`AVeL4̝B:۠T'/`u)K~V?t +*}[)Q!%YI9K3S{I| A$}OncigQ4ǥw1=}x'koo*$D%cZcF]dj6n5cT}XB* Q*gIql&כ&9iCM 0KJqv1y+iUډ&rWҾx +' ]+ӷ!\qH1S{Q0FZQBPZub_QPȸ0QBl">޵KM6&6ebJtU42&t?1k͙&ݦnP2;I H0D/`JT1ÕMz3ݷ[2Lk(Pn`)''u%xivt]d]&D'krwC֊Hi=뙹]@OHtuM + L\]oxyI.YAD]T(Uĺgt]S?-:Nxljf9X׻p*4Ry*¢;k0J!)b@r݋N-PIxʵRD ev!|>#6,l铺یr/VL"Og* +V4OCxճBڴ=Y8LC)KJ4PA`I Ȧ^t:3ذllO)5+ÙʇMIYKG%SL9]aH]QETZbrelnSB-JeR>i;B= +Ôqx9^ +Hk2%5j]KHf @ZKZzj ӊ8iLM2<,GbS)kJY`(ZP9dâZDF Y,΂F@Xt-2{#B6h4d =zMV%enG6)+QhK+ptbf0P&&9Z0 41M JTّR1LY2rɊsWWXNUd\GV\e\{U+3%5*Pl(8Y|و,K)bDp3U`Z#M L;ݦϪh郖GX&%W U@z~*K,h\= L"WQ )-2IWSН>eb/m\@Ee+9C>4\F0 B8\' G9S| A !eeL 0 %R/p-{E֒8]#"Z6< dZ/:"t3c?nKCRyr¢hdvM.(*CNg% hߪrR>4:F+hirnLCBu6hD՛Rn@! J +;oWx3"%i)+P`HAFa zJL?\Sw_0(U рGga +0*8PϮ;TzM]}e1R-%3"E+4; +*0k!<>8+kCT^nfܣ̵mFsDíB.l朢35f$Q)D Nÿ7K(@\滑R}0?@ r,7ith]!+U)@=@`5U `*QN fDqP-4lPZ4S'n֔HRreN?bh{5S! 1 )ɦ˶IME +0s׍\}$_k Pg>YR +毋1&AEw]aȁ+ -yh&@$Ldrꮡym]uغŭCM8GP{4swGTBuH&݊3$Qh`EW$LQ"\"O3nY=s#WSF Du0ѓP}5 mj?_ӊbjk\0Ry7$eU}B|VE%Q;3h&ۿԛIRI^RG۳%z:j?tSR$l{pFEZq"եhTuD伤Nh;o$>BGE)zMDơkWIIk3_9+M9gI rŵKWD!٢pmwʡ9Yk–R"}Za8/5\%(ߺ~ uvC7gh&US)MP],#RKOt;O- ʇa-%皩FI"RRj5vj ζշx lQ*RDtMm@wwCg0BkN"= KMeV urYS?y⦫{gRv*x&j/0ÇJh2UlI5XgGrTfZyU>&,oJu)UlAu) +'F(3qPNI_en`/Eb#+ >=zQg;EV6#Ik_о`,TpC_QyG59Z +HON@Tϊ;E6u/W!%~璮-j@uȽOmpT ڃIHuը*!$A+i̠ջQTޅ=STPp>{ Je(L-Vme[)E څ.jX'e(%2UpMBY a~pecѬ>u;FOstXFk8#6^ekQ*⠎fŅD݌ӵ~黮Uz]L(kE3^o__AO Sc[5c,;" htu79f+7׎[+oS;vt'UJ3-Y+al6t"xiM 殪0v.~u  W067H?u7t34Zi͝"SJR"d?=riYf9n=i q-Aq,-0;VĹJ?%?˧rVI}6]r?XP0Ş=CqsгefA>g9 `FL2^ Ho&6~x =fӈʝ NG #U#0i%41e;M}+yzuDpjXxADz"ە? 6zQ7?xOEȓ~J %o5q5í{͛k$4YaguA~/JY) /?c/$1#N!|feXA)k*%&&˴^o/mgm##]C`o!a,_fӬl:o?u\f,1zQ>d[x٦BtPZiz$s u"*)}GZe](pMAOո 4I'L +ueC/sщd軰 +e|F]໰ %?.°eR{_ƱZFf pfK^Bh+@Ϳ}Xa>IM#|{xk``ϊw.&ʽCD!/M~{ 6 ~~A fG'ܓ?C9ٛ=t-fk^P*{"Z8BTO֙W9^I\9(Wn4̋ 다sܘTWA:r,sV9g6sR>Εhɤ ڍy;f4\ՇOZW) m,Q8O:bC74nJ3K#OhXj^ӫ/g34bߚ>X| ƪJCQٳa5rV<y+n0Ѣ F0R/7VO/wK7>(xȻMԫΙ!ZkFo^n8~X o FaުZF~քWx0k7 +cFn04Jhv VǦ7^8Y8sna+gJެp_&>!o+uag[sC x NvX [,4Cr,j\|[>6y)OEm(YhU$㉛۝VPo o q`ny [~2kN%g'AuY<)oxLρ9ځO ΢qet9|W>zXѬz9AK뗒C ]3UXC &0U=;ZxK'۸uk\?_?ooWrqjrSkuZpbsn}׮ݶ*J o^]ux+Ewuk /h*<-幨V;q:iuer-K`ӓ'hoY8 +*Ye«Z%K<6nw +2@zɑ.-O +Q''ٿVee<G7yZ]npbǂQupۘ {n ƑgڮA +Yt[Ya]vVX|_eNKƬm#X[2RĔ7Y;o$AĂźYX_a]?+"2~YU;XZ0&#=ȉL1t1s ]j;A-XDtEq "ex6pns;>Ahæ\hX0օhY*ӛ_=,*h gyRV'@wʓzϥƾuY4 օe`¦gy}xm%xU?n3~[ܴf7k݋dxvEEdx phͳ|"o`/_;;7hCxTvWnSu ՁO^s1{RV^{ɳ^UgZ$VVY|n扈 X< ʀ*qvil,[QY;ё_Oaʺ=yowfƨfͺA,g}O2nFH2. E8M-VvZn w͊Y?Θ՜"k~~ҘaB8[(oe]`3/+;Ϲ-8Wpk( gI UXe7/ѣrF|vHik85~Ȫ~Rݫ;N<6z~(lX\WJFV8us|;\}7WpA by ^QqعzT]T1,> I:786ﵽ~}m7Q/(֭2 P1a H2DWNŠ([5ܜ3vo51Kj2j=,3Frqsp`(S n}8!g%NT-辬Q)93Zöfhe˙:'l2b9zd '-,6D8-``ЃB-$YTwNBaσ\ʗ3 @qs<37Zh +0[伇;Ś^8Zo+~r~h"yh\!O˂?94/_uW0sjE7}&ɳύ7Eж 0ʂ!Xmһ! jN_,o>{^?vƩuR\;6AQ!.Nj K#~{NlZ eǟ$Xy[[ }E`*Yyԥ_;^2D +7Lh]#[50'w +cAbl;_kb/?kǀJ q|x2I> gNN1ns0x0_W'Ӱ{[ |'~ؾ^'n*6AҠ;sEY .[9WTj\Ec+\*@nQm'v'pHuz W;ԄӰyThI l9~ܮ[3@KPIY#{Ú7yWp;`8#?Yz07z;6`9`c;)7LG|9PVlzuAf<3_`´x4|1nJC@!ff St-_ܽV!۪݋J[0 ֒|uYy c0`j坊!.ystN ՖN=2MZQ9:\<쵨Zuc 0hƓߵ)XpA窿6{]^Ѹ96o /^M4zX( `VɮȘ %XFǟx\ (S,d|'+0NVof/3FA[[-Of'_o$̚kQlآgz< +jFx[(Æo H'm;=y8Jy?{g8 ѳ'88M(yՃ"7/O_gg w@ҹG,NO5iP[U^U߉0F@G?{蜞.'jo3 onAa1k'&E#K]0@m瀅@,0+S:8,Cw((8l^y ww2ȓL$R9,Xnsܝ1 "&pZt!ڥpV pw"'X- G Ѱ֒HVzP[].=pk{('wzv0.ׇ]AصYm++I ~`RΪ*X01Y<*h\ȣR/vFOߖjKxH?H؍&J٣%'^4Gd3p▇o-h.⽤qV<~QjA^hJyo﹛,@8:up=l_q(Vye<>j,4G]V@7G{1%ou+3OWO3h?8<趧le]`5VJRhJ ?عv0# +@YG`dY\F̂> qp0X]k + `*0YX0RcLT^]Ɠ_kJ̘ 0xS,eESu'ֆ'_M&Ϝw@]$~ڊݎQγɛnSpy `έn9y]-nGONXܭkŰu8+Վ 3ٱazA jw&/{'GNNd] [G8*oY0A8:;_ׇoxZVbN[/y9AތADBЮ "6vHv%@@E &?|mw-ւOq_W71jSrXR?BD7,|3y'KQbR=̊{2>wAQѡR|hvQ]6(eX_YX~^+ofpUkn4-% 7^x`$`q 4VE*A0mmI;F6 pћ7/L C?$+Ã7]pY#ڪ’`@!P'"NM,x}'bgat>E^A7bu})e +|49 D8 hE?vy8P7XN=|96gM1povdzS#K@:Z6H\/Mo jp13uYhDU0h' "37nj3Wc @; `C=3@[#~`?fiu7Q0g&lvT*! v K72[6ɢ[d˃yv 64г9f{ P&Vŝ +fA:z>?r+ ;rs *ˤuwa<6:|:xkG `߼ڵWS9(:Gwh`6ϼxNyw`km,EqL x'E>*qNX(?LJu@Vg?VEy)n}\{|û"J/@,\m] Auf]ɹM![.0p59wUcZ#]MLrm Rt7x΅~bd/^ [LŊ^yafp~~L|}ムɅEiiot?)MҜquf@?i6/M|hF_^?)y3-\t|AFoV ț +V-҆EKST=@0a1Z;Ps ZmhRڄb4i(٥R70T6O{cRq!f5#8hƙ[;/AAIZgR%kV`[VEyvy _}/FxK o(}0ENmh5ynayP.)!9FH_  _X+8\zQ 2jpxn Gi8볜 ,ę"ا.zudZklL`Əkr3p=8qAg{}LA]ӆ7Hbǃ8znyT}hb\iը8 xl~ز4mz*[usnE[nQPʁ.s[Q8Z|CPa +mWoJsmP%݆Wdxg +`^[Q 5;3: `5ˍ30AU= 2VL'@Y`;l0[[oU=8n L  J>rq RI1R:} zd +VϾ߇?NԷm$S(kq%~0' ZiP_TYfF6:C<[XU85]}41&ϱB]H` RQax:s0 #r)i095Ƌ-7^1"o@1txƷ0n1 dwX?4/@}C˖v U ]ݳy?W +pkWo'N&3V#p}Y`- Zm`W-7-XH@4@9gZ+^`u`( j^JdȤ|21aU@ gOM.PabB [dy/@JOekE}>j.z<)w.ޘ=k-vV_;ɡbbYj+hacQcƚ(jQܫJԺʍZ>I)Ybt//;;һp|McЃn<, EˠvXn^ʝh^n{˼Ek78#^YKc'ZFw݃/x$KmTm4:-#E{Z NyՎ6jU7RkpƼnL fL2őj` )Ҏ9>00 ,okOԹ;b +b'7hGHi{YW׽w߶,wy<~ۧ% 2hզ%_swv.Ó)01FmFOc~UY?~UUK$> z7 \U%P=u^jx#OY*Y;5ל~΃ꑗY0-Fu[y}Rj{^ TOyux7OAlN0*]ÃuՃrmU`x` oKqspSQX}⽨jNnz[G;0;}`xP( ;/U}~/isX\N.__$dxœE9]u(G{69~ǠudG 9UozwnM3y,, ȇis:mǣvE6wEku2?I>MѰ5{m{&_Autc&E.l (["b @\ftٗ\Q\Iu:{29nxMcpEsN(O~our~_yrmcﯾ`i2q=Y|9L2{Ja~cgg&XEcxt͏~SV眨TwϚǓmp7?ecbr˿~ "k&`vL烓W/^yw ϟOm;8tZ^|n~u&[oLpx痿~O=|Q=^;z9~9zSj{a|yp^^>^yM7UFQڇ Xw?ٟwuϣ׭7ǯ4Mutۘ?5y9o@w^Ϯ|Q]W2j6~x?,`,)|~-RƩGg?+YZ>Ż8{/_Njl-^U-o{7Ko.Iu)fhwu{uWwi9~3 HB@ }eyꇞ3%{kg條`yUqÜaBrԁ"\"R]]^X8xBz `F+zK@|X* }oHq%׸JDsBjjA +zi(3ZeOfݸe&7GRVvq 3q,\=x0gʳgꋧ+Z[kG|FcQH'jӇĤLibk^OƳSZtsFU˷I&t Hظ_sFs3cުV]΂!e}ɮσ,T*qLj lrq%hA59^e! \l.Y9KjB·+nexklKk֬Oi,v*AJ$v:҅8pi/g,HvNRɋ {a>O15?'Jf"XubB ƚ^cC@d{Gp+ 0U13wHyPm腉n, +v[9\:k\D>{O8FyN/fZyի>G&}'/_MQjeS-,J^2i.l>OY 5kS}Rw2 UWϛ= +hĂlF!u)݈f򼚟SVoIm:1MI.Tז/H\c?Dyhcjalwxtxe1r҄RӑzyG~-Zl4&{rSJf]8y2Xb +S'z;<3~lnTo +r2>&&p5Z&v8x?@)!{2P."ēZz!|3'hE[xHV]:ot*: i->^<%[=;u8{>ZnSL0~0?qQZaR%5.%6Q7E)ű3;/NZX=5qTrLǨ-Eչ?wWau8PP@y63Rvz!JRvM̪RfލA>^p1`d!=DŽܩO/@U~:nlVѻiR!TYQ˫VyCNDSx` Un'铑Rv &(9zq>,t7L͍ 66m +X6{vFja!3y"䒓\u]'>jCN +-r1PӹÑRo?} U}$=VT^FG@͟.-^Fc\R fX:3sn!=D})0sXs pSzl xa2cjvhm&gvhOJ\OEvHcd +VH)z- +G41b*-L-ZV|P݃j;o^Կٿ y]*gbZj,\xuq#3~JL.V$zh3X=t@t”^˙0XeRFy)7u(NC'U3@6j[Z36"\HC$ӵ"D 7Fx b03A(g>]?mw ZMRrCL@w3 PP} E{{Wk\.<ԙ?7HDbbGրhe&XexeLaW@ނ9ܟ#&fxX`+T5cdTSJCE`99\2;< Q@|jŃl HXi!ޟlo%:;H" ]ʑ>s BdCW9@7GhH59,K{p! 1 ٜ| ^$\L85$JC0!nq",nʋj\5G%}WMiTOVi[>kyZa|̉0W㸜4Ĥ`>/%RqB,8! "jx"@6qyA\Y @tp!V[Q3vAK^8Ix-/a+)!ղ$: 3 v_k< NB*-/4 ZHSVȏ@j991ί +fVDdb +ۼQx8Hyvќl@&!^H ŻBt?k1ߘ%Hpo "JE!FgC9@%* +Z6U_JVpم. +66/ ',z-Rݨ/LĪQqBz^s P3 +vpVM&K3Gjs'rRJq氓IB 'zA @ +m"D-B/ؤC +z.RKNX ep&兔QP|םG >6\ $fATQ/gv 峬 +eS 51BB6 Z0d11)ǒc .m v/ K.%63lFSrJ01OI d'xF3hpa50mMEɭ3OVwdJ-jjqp8X5-ErDl`G.<8&t=;$'l(RZSU#sE2JA.5eTƅtMHU`IbH\an)ҙɆvۼr3.a#/#R֓\SJޘY57FK "n.f Vy,/8ł5tQakJXQƎJ6Ll'~#0->΍ ک &N'` 1&ǁ}&;Â19p@I6@HV2RGC7G`.Bfb54<*F )Rڍhug FDAjB\tP +VbCE6RJe9Yӳ]=7).Q GplB4nrehcwqRJWWEˎa3UB1ۀُ +pa3^zyؚZ^xݐp f"wLZjZY->q_xgٟ`Xgksu a|Yw@y &2p.wN]}/=/pѳ3^x]o^c|ߎj)b8*vNfw;|+䵟O>_ʔ'1Ptjl*_:Y B#16A^DAGٱ.޽}Ż{m\9noFszqy_z'_{?|_O>ox׏?dkӅMWU5g#'/tG[+o?o~|=O zleb#Y\2W_8y~܋/~7_oI_}<[T-()tڞ_Wn> ?~卧_|^ko'|_}♗;Q`Ah7V^c!`Z_>|=w?ݏ?wg_y7?oşy߬+QRQH)$JQmM/m?tƽ3O=H>?O ʿ?Z/xyGmZ3 bY|՗_(dۏH$ ˪!M/[[\Gy߾ѯ~λoW|鵷O_jXkp*F,S^]^Xv[?}w_~p|_|__;fmWK7`Q)=||wk_{͏?|~__~O?>{|k!tIz5BņzRo?Cogw?~}7ǟ}7|/ҏ_~mE1Z PPIM4>~KwWÏ?կo$;?r{ՋN ^ +Wmǟ~W?ۿ?=Ͽ/|Gg|87&#>BE=F +Xena=/w;7>'~{%J+aOt{90Kܑn o~O?KkX+U]D)U\35>413p~ʙSg5$I?Վ8 +F0OvZژ?zlͳw<|b{fAfX5$.}O#IέlJRBf{}c7У}ɗ~񳧺9}DN9rz5&ՅGw{_O?˯zWW_}G<\ީȱTȍ#vk`s{pWo\}W^󧯽__|#[l X Ƙ \ +F$G"Jely~#=y7^㏾˟}w/ endstream endobj 109 0 obj <>stream +K@PIg"GLLL-.S(Iz.i60A @[.vHG Ğ DWd#ѼN! AJ2"ɤ#qFvgF + LrEV2㳫dGsHd- ÄD ZXSX<ʔkcS EǚJ>@!Dᒷ=x!TB$x8iKh݌&$)>2I?Ym7=&װ{ A +D_|n95jbq36Ӯe%?v}ցA9I$dXT2!=Ob+h~?j4Z8Tڭ^{ZD,)Q.rЊXQ;0JQ +8?Det[>ߜ>*(1%K)RF)qM#|cqyeD(ߏEcdAd84 wXg2yNg@ +b&SZ=|$!(S+ Qܰwвg2`Z,tTkFŅHH& Ԉo`$yǔ,*tC0R1bP*6lϼoN CY݄n5^qrgㄐ0ET㴬6 :<~ c\NNtz؂bG;Y\6+~̱F^ Fdjbs}[?yzEqN'lH#,rF ه6{C# r%g@q9+UG,>AyQCE U=dIؕ@BR@aHNDlG'Bhy5?~\` 3? ~H$c!JtF{`67 !A^Pba'0,Aq_4? 0IVoFgp704;,ۃh`vd7VxtW@Y3h뷵(&)d`2}#ށK"|Aq1e4<.I&TùF 1viq[Ia} ?D K1jAֹQV +\60 4н̗dL +>6h2lz aN$`sƭVԏ@޶״gb^=!#ܾН~P/!NxAz=#> 6`|!lχkC&@s^ -}CQd +¡hq/@`3ِ}N!2&b|qzK8|͊G}0̗VqT{>"=:rZ;e!2V8dr;C{t eR@3|ip{f\h,_ɂm(ox+uVbqߐ׃ȌC v'} hוx.LcBP +#-6҆ [!mЌލDuz ڿ!=Onn_<R d(BkMS"erXPfZ3\x&b$@v v.L'=F\y咣b  +yϐQ2hsb7 # .ƤG'T܏΀6`ݔL +N0KY6#N8@-NzȂ|6g k|C7QI>#yqT?bq/iɍx8sb`XJ%] +~54Y?SX4[ݓlCǑRڇNއh"kJ)%F.(YP GG,0@cQ/\%ŬEZݔ Y "|o`x :3lc #W7wV*TtJ. >5IV{m̃n&j1&/D $:g̚|2$'$Ml +&1J)h c!e\ +1]5DUbMp.E.XӺ\gرg XrKE 8v{ &BF-3*`T-d?a¤Hme~TG  &iu>t5vphT$"|l}A3drb@BL 7xRSL}^Oa2*`rM>0>qǐgOyC[x-8 q +m a(o5aB‹F wF p Zî e윗{U,X3;VyD̓1M9=jPfns./Ao@qQ+>j']ppjwh˼B褳 awj,]j@ :4 !52q̯_ܹ6dJlROӳ =0v2J{"Tc+l?zQ +3Q߈cpbcF#?j.% d abQMQJ܈jrP$`2>2 y!2 +QI'fP"G}J1^ZfԤXǃRby' ~rrK0IKH}Qs BtӸlvQf;oS=\zІ8!>L3;H-6HV{F|#vW:OU.Ԥ*==Y4\O2m iL.@TT2`oCFz:L&)M?4ja.:6۱Ag`*+E{IIY\7V9)) gv"{/cA|L + Z I:U3`q ta"Ba +; +.-__'a7 n`xUڪrK73sGMy+TZ(-mRERMt)\i-?=%e\xȏ)Qn +pfC,Ƚ\m'Zgm>} +RjKzCDGȺА^GN=dY%d'Q +KsVeTs3\^a@3A ΆZa^o@Jת@^fqnDBPjrJOa?oXbPS)T)&V|T| +Be-ZLlqeVaL +$ؐ%00,%$VLW`)ǺTi\V'ϳa7 NDj9ƹ/z +NoToVrmh`pq(g%Z+Dc3R^7bM%=TV+ =ЛBwz~ [nv؎ypHyv]KvD'R3!;'u)n9u\|裂݃njA`LhF{mq.(H`cfl~XX<+Gm8b|<=x)O3B c0DfIBkMtQ6B5Pűԉwv(NąAow@v,n߫N} SgOrxi! 6 r)O.d c hm|hd20PZ5&b\>RXt(LRz` [0XJ5*@!!á}n9>Cfh>:5j,Z=Ӥѱ*h)MSzeDb ;"oƘ//ũ=A+jT+đh}I<4|ҁɡlwZcJrP}Lum 0!$Č."yh+vIj}mhE?DłIQZv$"e1)-*$)Y%+sp]|w<%x"c4Tn3zk#V:鷠DpffKS!Pw?Y<יP;Y=zfOԵ` ÅgBF`ag@փܼ=WG=.P kUJ7o`bb +Vx +Ț4И>Pد禤,6b |l0}KN^Y܌8!uJ<(/y`om`&q (ȀA:pU&dVBWʏT}D$RYH<;QQ'.A-ry:Jk> lqpu>4bw`s$uy_lϐ9ҴĽ#nL:؆ib9|6uThÍ"+r)ܙDj_1 -p6!E[3vHpc1:=L.r,.Q)^߸zSL#OڽϐZԑJE?cȻQI>㌶36 Fq/cl<_]9 Te#?v/pŔz?bA3 \eC],,X $,n ZzF/Wm}pz2ru]0e8 YYpn@8@h-\ZNK(>\Sc6/cRzfcӇ,_IO;}0j?"zG=v5߀73s"Wcӄv؇HV꠽s92X묞)GeBhbﮧs"%fI)Ǩ%Z.wC&s"W]>!0HI9PAT0hq\. +*i(rB&pe :ǃ +:f0.5lb6',dR*FDj\EIUX^+wkSdq~n\6s153jR3skLyZ Eo蠬4N,x=(-9mwnr,o]ӓ>qdĘ連냷:BJfg 6@L@顊r9О? .`q~Fkd v8cRµJi|˝\w?lƎBkU91̯erjĜ ` j6 dNloZ1ch Djhybl .Hi'/Z~NLÕB{k^)U 4x6ҡZZ̞W(5rw%MNRb.^];r!iA0btD KMzء2.#fr:q )a2>ʰ +T̎d/V]RSZa./f)r=u(ds @Dwxvxjx=~`T FKޚoZ b`O'ɕ~#v J.c|r&QШt ˸XEÙ݌I:px+Hd0τJc?~Ak(lLsݙ;,9Q/zy.`&ku +V{!bk@ { :F͐ن.E) ! Sz%R\6k| zw#S[e q %rаg\˜Fkr>D!mAN0ZQLF$!)>]l&wqcnCN` ~D%[uA aR)FKa\MfGOv-GFf2̐ l0 $!q.A)L1dF9奿t[*cj0Qcxqwޱ[3<0}V +rfܴj9H7хݻ-~@&[ v|No"M|¨eQBH{]BEXPa׫jMk|YSJ5.t[dr0Dø!A8~ϙQlv oab`v/}&k_H5VʄPR]1֚^;S8TPzvD,?X/;(|t@Ԃi6FZN ZI/l^*v}lW`Nˢ_:kBd(=JN2rE6p ^lϐeT.X~wq2fsуf/ z#QYȨ,vǻ0+`m‡!+eVL OWZ9pPdʲ1@"HC +S_P ch(9ar@e~ԁف2QɎP8P\VbbTעU([=iU'tV;n.:Ulnz`ie%k5XB? 17f *+?'ҫ|j[W'7/hX:q0][RD) E0ʦ\hvQE]ӯzPP;`pR/d0V]=Ό|D͎7W/Y=#mOLe}τFp7a(? Rv|=;AbdLOPZ5@]~![Y?/ +\ +fQUT 3r"l.Ml>$͇wxhiI-?mK3+P`=B:&%M~P>Xry9oe41[q: (;Fn}{ wC gIZ/é݁j K )9r'rӓoõ׀gdCpqVL 磓I|b3Kgxx`6b]M `QRH_&۹Zuj'S=S-D̅ˡBupb 1ڎ$:.zʎz$LgpZcTԉ[kBl8jjNJLs>E)5\[[9yb-^|:@Q.Y!6@Ƀ:&@Ѱx +ٟÂڼ#,Eb \I'-kMUYJBk6aKA1 _ߴQщvg (UJDY#UV-\T>F4U-X@m/D >-tM7nf##ח+|fWN{A]IV0JTjN/P>7mtdg6,jCހJr3 X .7w>eP&I:Ґn=ӌv{6|{8yD΀ ^FK7{}{L$ZPBqVcfM.Rq: -Zdqr39`v"bu9ޓuZ)kn†S|' h[KeN;F|fFi*&)٩T{wx>K.\AAMfn&pOAy2@TJ|rj?M4fi=Ad`+y q0jEe1/>H85cf'm#lRKL|VSZEȈ/% ʸfc̼lݘ;`ikv$mgaT =:ivG+n3Ah!'Gǡuf1ĩAO@$R&Li|#?\|G!d\p4F $q!K)@]/&`t {xLb 깛_xy%N͜q t{hz嚙er:滟scnK~6S֞# Jsl80&|Q pj~:qr'5kI%^8in>޻.e&1𠢠AmQbE#!KjD$ , ƩPoI)4v35'ҽ^lkRT3}9t?, Zܹ@šn<~㧃d惷c:4 YvX~w jT1!ڕDgy'7{Us68@E=;0kȰvQ^?nA* !\ gD m8Xon%^)D +@u 6HAYRW.xֱ.%=G|蠛$"i\N LwCQ+LuP jݼr D͏~~âdu_:TUs^0vb٬9”9\TۅsðQ)/j!.B 3l1S (mƋ}=!1+Vq`~U9-y+jwm˓B|?V1wXvFbLt@k%ZgK+k'{[fο[}woun-}??z0$u?,W~4E,FO$(8F-QZ2`n *!1=clw6ʋ'^ܾ_[{nqNN]|e܋#blБYRȈ65L M~Ƈ!&_8gfť?ػjf0|>ƁנA)|\u沕_d QuGYRm+ʚ7`Q.6d!ƅh=ZKזXhT}+tw\TWf'nHk+FЪ<`T>sn0ǽ$)\=72d޻ǝG06.>DZN <{buYQK7͝"c@<ŕTݺ t^xqժW/fʆ[ظspe֪iln95Ι Rɑ ) +84df1g&Ds8{!5yƮld;k &03FsB-g/$)w7H̹Ht +.Hڵ0y_\"?egbiٸwW#AKeCN%L. C)3@=y!bSݱkaG;O/x4Hs /|u=,'hB!3+P<5_&. `HAU}VI_y;/0+jr:Q]߹}{˄\ 7!İ[Ϗ?wXؼ'd<D'=#o0*p0I1GN{+Y,Ζge>{:s+??Zoރ׾nV~9J,݈Sovi9 qA`R xrfc岔m],HcKSP>tKi/cARehO qYҪkyXfX +>I"@x AөvvVegUTk}5?m:;/p8"5X/A:dU1%Ŭl7B@zyj)?;5 Vچma$iB +`0k^ε{oOoވd 0rq壁OWӕHzjG?!lPe"`P.JF2FsUlX8x\d>@,Fr<{z`""lLG)3W_"o=7:k5{֍o3rtf~9C-9&2\irec0L(B<~rt >q<PBm$".ÙmNQDRJ/͜wZwgwVfvҢHWw`U4,%H617Ep>#Uɵ;7ߝ:~n̯~Jf61y*goۼ]~{\,%./tFcver/2JM) ش;//S?wbmrW/?z/VYη7/O6.Rgڼ2F kFĠ3L'*Z#6iĕ +c6H+0Y0 1ZZւS^Ul +81i1z +*9̢iqcM@h3W3'ݵ]CH0`:͎;`"B +iٙ /Ļ}'$K VwZy朖4vQmA#T`3U'ta'!~T*:H:>y:q(eCĠ$>ĄȠ'/(!g01&*`d)2jHĔ)n,?ofi+`-rz QBrl`\ "@pG'N{P0= DWolg֓F]lҕ~ dhVHMpqoq%1OEH^U1m,%j+zvdA{vN{z{J89gWd>^zgս^ݿrgͷwZkwgww$(ݧ6GR4)%Uc\ufnћ>w߇4@xX.J:se;!Wo?-w9d +Ak*rBM[ PzSB/za^E3j0 O Ĺ`D7jADomJlmwQH̢B>O]e`׀eГVns )`* Fب N %:jfV|U#MOXS.D' B)m0 +T'ӓgƙL,)S.TJV PG@ջ1:Isb{c@$7$m%n`F R.5"0*NPrCAlRHɱpwKrQ +܁N6T5S653]ngJyn`[ݷ'7n"Ea\ &kgn+>DW;5ǻS+w6!WB7pc EŹ~_WÔby\kH>*<50@8B.^\^O>5*U!9Z\>|jݸGߵ=0'a7 01rx;wڭWc~~5vdЋDB3.HR +熨l*JZ x'R^Q2}Ԓ9-WОOs&C zOBlS>pnb}URo^qQܘ0atp20a1g2-flR.^K#5F㦤^28x-vv8--^h:ahdi>*$k4KQ%5rfm|! c D!|,Q ͕RmP~'f7# QOBˆdI!P0 Lh +)fv>RZVb&D}L0ʱs )x3]~:~ 0ҹ\} AT +18Ì OL"R( +A(-=!1E:\HwH G,,P$ rA%"8cBp:.yITKK@3i 0K^\3GCmtp`JLb lrz*LJznnE9-uΚ0kڢ/$? Y^K]4! acJX딚ǸtX`c@P @L?I1()#Iu=hE3K|s8 1Ni:R +#儨eHN_f[a +B8BmFS2`8>0&500a[ +$E"6oUp. ^bĤbdqRx?M4O +IbaHDuoDp¹$h! ^ `" .T ۇ5ITi!8(5UWܜ+%!L75iILMٹuɊαhO) M3=EIoX Q~X6AzA*ca H j#M&BYtR=!X +#F*Mڄ9 I%"~fD¡0F(m\ pgGc('N`$„CE0kYC4F)]QGG# md482M3Z4" f>)! { K\#d4 +;Č`VXLwD2ŽAjx<<rWNnRBqOq:J(dsڅFFcc^g8`j4f"O cl:eXEDa|A&%%yP@x VDg&D7#bLQEDB@@;Ctx@`6%D:] #<YJ:-ΨXn9 ٴȠ G1}nwxbav@DD4*p D ")UIJ`6B q|Ȉ7!L/+GPO.hfnw 8m''qA\PҔ` ϏQ 9=}bbwb$D3$nIgo$mBB8S цt!X(GǠsaXAix}Njx{t4"q\XjԈc:6| liN W58#|a'H 7dsgo=7nD>qHA)Fr2d=miǂ? ~i Q1m'd'Fi#q``2$(p')r$o!u?1IB1O4<2EdfGcN|,k1BA<v![JO꺑ux3< #sC=?7tC^IYFH,Gv4ssyB ;6rX~SY@>=4s'O'ǎ{ sp{ O0!ZW(H\EE9!b1 " aObTr\Vw9uf7n_Z6+vC17Lw$B']YZ&kUی)QKDL^]wcT GmIbDbHPN%z%gC3~r{ɝsOz{68#)9a, +لժDDvt.ARz%W֖Vir*!?<2;u;aq#?Op*l EH +!ATŋ[ݮW64~UbFA%X6sB1h ;3T+Lg^ʽ^~gI'B@D~ LA0bc$7Uѻec{ۋ=ޫgՏ~s]/ꒌ&A ,l"bDjrZ+SR]xn=ߪTӉ&3H?: q)vc#|~#rQuutJ.QΧ19~|CO'Qz ܯ;gz'o?'zhge*p(=ALxV*ENp3w߼wz;8__=.بrC40DI,\G+o0[}?~v_Ӈ^X9idSH53Y~Xxudtm*>]}|7ݽYU&q ܣq)Q Krڟ1_'w|kهO_S+Gggb\Np;k/4_}W?{+>_<{闟\A8f%Hb""N~g+Ż_{</ׯ}~ܿ?{/rM&t=~RJ*̍7f\(?{~}ǟ?k?ۏdgY z ;B -+KkiR'{+>|v'w2x0DR(lҳlA^V߿̓q߼w{?{_[Wwfs$R$h"B(\{Eďݟ~gۿի>_Gw^PdBhqTlKmͣĻ?~uO^?/?_̗ÿywv0([7:ZCb~b\OׯG_}o_???ieP c+aقЉ:>y۾ybG?{wVɭճ'_~zJeO:r/ETQ+/w?|~oo~ɵ߿9H,7>9i={ugo~ʯ{;Ťp$ᢢu#1-hbQ8K.XZ%MäABJmc$ +mS6S]>м{Vo?խ}ԥbIvDX`м^rdHgZB}quͶ??K]Y)<:_}jK\:3/tsb%e倷1AsBB2RT-j=va&{ط_?==?x8{Wv"۹},JJ'1: +l w@B+uzl{o?^O~ţjWdݏ0ʡ!? #,7zgŵqWޣO]xlg\ _ GSBmNtέ-3ǯݸ׿Z]>3 +zH1蠔1 D'Q#*H +ZJU_mFW7ZGU0L.MGRX}!H&jEB6^p޹|pgo_}p]^m^Y0R|<ac|MTIӱ9r4kXɝ/޾굅[N,bc-\AyI0jVcKUwAma`|=Oޝ0W8LŔR<?;:l42(r\q +2bҩ$㵸ٌB .%II'y1a0&%r2}~őpJ!YϷQy__-lץˋsәYՖ +Bު|hoUZC(qだYMh:RQf2jYǒZ]O)J)b|3=6R )afp(f>XC0$u99p%c\l5Vcq"(´#G*PlR!28m9VBT܇!&D9EDdzfE +)&G&|? h0qj$x|7 :[m/,y:`2J11QD#$#+0mS|&DD텕q N/J\@/,1]O c7O@MV)3JF]4-)FNxxDGRrQ^IvLCZ@:=3-kٕz| ;1 }M`h-)ޠ|_ (syO1F3Y>e/"RZJNcM\;8&݈FmMB.jF-x vN2fWF7sC5zfxeU U<iY3ZiS-Jxae2ZgCܠy $R!G .H#j oHa UZrCXq/DdN+2F^lX5*5T@c&!AP>Dw0j\)B|~$'0Bܨ rJTk 9Z)θ_ `vLA\0&!6yjCο +`D\,$cT`L/ . 983rvYJ-G,)C!a\@`HO+x)5Jo+V-KCs'W]}~7tD{۷< UI݋Vy=:Vv6iiޞ9XB.̍YZviX<82.H^8ɏJÃ. +0Cwx_Bm0L\o3@LrVڗcXQu:2E!QTxШHG.YU+<Yɚ]Zo NjG 㔛a1-!cZj^❩3ka!' g0blkdFVDe ) +. 17;`̯JmG 䁭QNL" O&I$R|VR PZ\lVn6"@LJszEKMQHɹtRoM?!M/_ 1+rig5hCf~ˬݠ*medTg3VeKt)6gX(;3Qv98S.efkwږ;;˳OLXtUr !>ZW_zK;k)qø{V/Hy-D\֤2QeV)g28'#Q#b/({څ iNgr'=a%DRk4aa.5U4R>4fbrYoG;uj +X V{g_+|闩)7TZ|.&gݰ--tFq?ھ^veX "Q]'=HHI>b7/JNlka>DΡ]a"=-ffO DIؗ] e>2ev.?&ZwDJr7 ][3Iɵ7_W?=bӀ"H7~$C>@8gު+ݞ}%5yxyaO{Ɠel5CiHu#hpNv6i [U +ėNX{tj[Coj%?{c[Ʈ;\{IaMH'@Xjw}HmkFij1 +R3ԹTm%ه iHʘ$b]`!EMz69u#p3; ̪̟y,΂0 xv姻ߩ,\6Rԙr_j~(RN5wkV.lW+ɥRJ\čy\iHERj}c}TTJL:'^~j{!I)9׻[wg_;¢^<":x{kcXUѱfyu R|DʄY>fWbtJVh2Zv:3{m7RjrO--6,gʩi$eKBbFv H,+3wT +ZWxz;=T=JLnp܈ )\^ffTg0iB2?lݯ.L]).#W2ϏJzN. yf`vNv xȍH IBipDڞx7@RPtCDrsj"# . ^ r33Ne-1RKT7>MWSM)b9csYZb-%VmWm1)L-˛rQ,97.\|ݼfܛИ@ksgۛݳ;wxI}8 ++[ y&Wݷ viMH-JdhFjn ܪ(&]>j#n;FsB\Fa'*%XR{@J2 A9.ǚ[&0q{za%@gַ 4*fe3:mfn;3Ũsמn4RBmzrP!JM%LgJJ$?Pf'=-o^xun5.uU2%g.>Nfd"?)1qIQ Vyh;Gfsnj;ʂQoL_baa e!jO ~`Z^2+.ǁR!*\.fur%?iBv܂z)H謒:ZNA@vs!ӹ:]Ȟ +>oU()UX]_1$7w6BL lc-1RZ3IwV5 $b,,Ս8 Ƭqf=KT8$bBOOBq HVJj>l ?(Llcvq^=۝ػ +J{]FzYi*ۮ6nz:劺Aq]B$@} H'ꊫ @S7Ldd\?F%u`zf87CO}dt +W)| OGѫ7nˠw 6]H7G~zgc>*Y򇷠B?}k0˴֗sp?{ښ&wZOj?~7 6x;qm4ڇo>'?NZzos_>Vg=g).bx/c3/;HG7 N\(+ln_J楚Z];O7WJ #]:ӏ8ky=S_nfo9.(OH:}h f;UW@R*~~3v_'݃wS:[~|; t2k+ޙ+>(gnp).4AS ٝK9ףɋOj0) P2#V?#ww[=Xxos%m6yFȆIʸ +Lbu':^~ɨP } Q2EgN<@ ^SJk(NZДݥ?xL^bBĪ7 y-ZN_W&%֟SJRBM؞WJvz~Dd1Z}1ZltۇyO~;]_})C[ۻQm6y6?&ފzGuw[=Y/}=#D51ζ79>Byn<_~I A-%\Lv'v6{ӋW0RX>1x7_`kav3G7ϟwpٗY8 7sgpb#Zs5:`1&%mph};#}YC%g/@99g{C}`U`v L"k%#ٝw 1@hN`!x.Xg0zSf + pi͌W8{"]lt6+m>-`A #T.P.0@~Wݫ zofvv6j߀ >O9}B$J{1z.8SuK9VTɎ|k;9t^kl~ޞ : o_~x{N6kM/׫g>Aٸtg{AZMξ +g6pK0 n4DF7`M8=5¡Eg +EsBAъY4QƓ.7݆L.@Dž`Kf2Yl3vG7Ͽ'wuOSdi5@ِR3/>xt^fA F_K`ӧ/ No"#JmڗALLUcsJ=DGaTj$X(0ug5$H5\iۼ;-^cZÏsXЀ~J,BkW#ͤwy +fb|Y3fï7%D]?.nćJj]g+Zh9sqrh Jh\KŐکsE|rORR~ms1LEu\Sf=Xvff +yA5xϾNu79P?y:yn2]0Qګa&ӨMlSn`B%ew +=d609;HP洒rHFZv{ӽ$a lOuojgpt]&[hZ<r7ן|)V/LUh[;;VktpQ[(ㆃїv YcXW#Qoq:y~7^FZK ׿Ϝ)$16N+ `H4|::~xtcq1L' k J=?/*${:BVd<"??ӣ <,WUk<޾Si\кV --!^~o]' +WA;g?֛G㯣ѭG0Zo]r"M/\J7ZBJh O>3nɋ_/ּq^|^:+NBAU2r(h͎hi6zv!%ofﺛOdoЖd_<.\FD +htuwPmN4G~ko5Ah~4 ytS;WQ@< j}gclYk8_|V g:nBFatʈ_>|+XE4tes"=LO` Jucd-p&1>C68OWz~ 3EƢ=#>&W?CSTuj8aҳEnKBITk5vKswdͪ-۫7=Go􊔊2к?f(i]&7f[AVx(݄+F0!UxᕕB}~eΙ{RluΦ?G7[zz(@ak l4MIQ[ cziw)o X%ׂ^]&a ZNJ֊)eN5Hd +*pP ]Ǒr/wZTQ۪S!!I\R9*BW{ ̨!䈗1R^$?o-~wOՋOQ֗!ϟNh$⍉/ꤳW˨)ߢDfu'39*ڃ~Rrљ)2_uB+:a6ڻ gMAၬ庙ٷ^ *Tz6 G1Ư7Ĩ/^!2Wi##FiUPUࢵz^ b\\cY]aL93ނ.-$(aPrfeb%NG1(J4Yc 6@`^~2="N +wm[<ꗝïPN\ [?|5:׃׳.x2} :n~xn)u$9GՒ=yk%Xjs}N pSj] ֟w} ᾙ JAfaԞP[Dt A<( yc3;? +uʆ_ŝcJƓwt_CoWՋ깠e>@m"SM + +Q}?S۬uBe +GoJi-RL ek՛W`x`80B(=+TP::S 7i毁 +n4 Zӛ|t"*Tj +&7;ZgڭƓ=F3je"6LJN{'C/#v2jԘ&='hwL껽2'x1Dgn тqkt#7gA5L F"l-8}3x+0-T5x6SV~ 2F#^iBqC~cNN$5[[3?uWjzLY:b 31Vmyt6=o5A/V2HJ"_hPeq֯b#`#yK}ω"1`v@D6X]w;;Og5-C fNx=/R)S%DmQ4Dx0>6p%ほc0Tp jVd} YCJ*]Ls"3QQ r!f Cidy-ea1rWg-7gpY|('8kRTqQiz-d \jh<2NHL"35A\u֯gFc!ҍXCɅ=^B`uEuꃊ>ǨHRgЉ@NrL6zΥӾ2AZ_ u st.I6PS5ב-odwhNQWh?]tR <v6D#/>4ǂ91\: o_ +XQM X@Tq[gxt#wק~yw˭*AyNL֠آĔX1-n`I)֨Je"kt.dP&q^UaNV)Z>HFROJ,K5ԄcO oߍל,Յq8Vnc+ )DŽ`]L+cxf\1py+ /%g8SrT|]Ǫ!7MUA9! 8)ꁊ{VzI +(a2%c$!'`XxwRuua~ 89ͳs#Ha\ જN᷊3Xcl۽5ғs.Z)ʺ S{#_cd5$e3J⧌) @nί.`1~0\$a8_~8L +,yo}з/$f;W!\ƚ3+/ ٴOX1@j\h;{Jܗiڭi܇n/̪TC!Ŧ߻oQ>17gDe6hoo@oοUc_Hԇ p΅dC`1x/Ž*SH\Ls nyR +&➷i2 a#(}0q9m_.s +Gr!xX}Y +C{uZh3-oF?~/ €> CZ y{ؠ*阒;=>.f2FsZl_1J 4!"BL` +5xEkPB^BanYcS$Slִ8_E{*Ą7 +&9oAt)X{ +lJ=`?\,\s3?|\!W6JiQk85c:_Ǎj)J[`(! ê=)Q5D-B jt޺3# Piŋtxq)&NH7@`*7D {z7sLhqTEXo&2]0zj9H +U +B{5Џ"p(s&c;>FXWfANCClZk4hmUFjqf_jS"kcu}! BDuگVmp _j|Q*Xqm TJ9! ڣdeJ$aa!fj}ANH)p(owŵJelP.FHo j$sO_×Zt L;'7**E:G#O['[3RM#Z_|wJ1"xjBL0^y0z\ЊS7K߲ Ğ2(>} )C?m Յ;Xɺ- +P©TM0-%mF&N4*j=DXA:iکW[ړ5*(>{jޭ`Kh ƜAߖPF {gٓn_e)!wp>!U}\ãI<ݘ3f6lpQ0+Ϋo5:(zZ| +HEhӾr:`: +aHioόO3BjاJ ED8kxss;=KG/d#mJoNY op;?zr*So~~YZ|+ )PY$pi Ltп2#FZUU{yIf׌fawfEӯ@.uUs2;uWEVl_ Jl +PTZzʛʼnH+Rʪ̧3f0#9֣UHsBi):c-\T+ +!#0yg,J%y;D{4XZiAcN\-`MJOfiMm `k'pؒ{'Aͽ +fw .FOJF/ jۊGzm%(lXgMzU>})U"=7i@l]fLW#6o0qN٤s{v? HQ`š7.&WIk {Tdߠ, );p[WftPADߝPjKk%XL1P͚øw16 ݒC'=DHW[NXO=!XNqap/HMȣ:D0bnNpֳ[oLo^Ebl[5oNp1у f0R$j# ZHy!ናKťMes4 (͹UHRLdstƪcI}LcuP%cI0~=pSޚ@BQ^\ˮs +͊kղs94nvv ,yk A|A~ch;[ #Nd` 5h|JpAI*pRt9fk o6FC(.WC} !/Nl=7E[ ˙=|~m:Ь4hZ)$5lwRh"JhBpJ.˫`d-y{"tj)ՙ +)Es_Vz P' ٙ(B>L_yw=NUܩwg 0{}++C!*EFMp@ tInrfWeRoּƹp*s`J9FiҺ%iHiK@C 澗_ TTBŤضc@jl-M᎞cb ?&1ZGNP$bQMk_ͯ~/>(,PZӠ*_|fH `}9#LJ+򉑝yRaj jMQcu:=D6Bj△~mLp@2}}WucDSZ`eoU:C-0PέR>}}spY!Bt9VLWJmk{mGeLٓJ9=cR.ǧ]saBVyٞeR]% тEq XݚZ%Tlf +Aڐ )W;2 y6{._p:W9L^]#܈tP%]|O_޲Z&;l_@ug7Aru-s Ys?yo`'5XMic֘CPR2zt2B'R2._jկMR3X 7=,No)Wsހ Tp~sk!)2Sp ʓE2yXA +HKԺ5ĽW궯Gr3n]‹k}㽺r L0hqjvVC5ApgVHcs5"d;G~y񩐒)L:=q-)Im 6tVZGR ,V,!d?gW|/kI buF-Y4yl\DEaupgmq5\hiE[*LA6\W@.aug +s%1fRx_Wߨk%_%̟2he;;jƸ-Wuk0N(CZt/MJ;Q'ΝN Mٻ~ 髓w;j_WҦw0r@[{}koa = 侵ܷ0r@[{}koa = 侵ܷ0r@[{}koa = 侵ܷ0r@[{}koa = 侵ܷ0r@[{}koa = 侵ܷ0r@[{}koa = 侵ܷ0r@[BZ_H/<1O샮%':]^-/>>x2iWdvo!uxȖG{l(8XhYzTMyV_دvq}IfW''~S[k̎}o?0HO'r_zE#~}AfMNiϯ5qo:hϹ+=?h˛2Gl x8Mԣ# AaXTۿ)~2p)Juxta^MDhc%19 ʸU<+*]BSЇ٘;Vpf S 2DsԄ&T2PCNxCmL1A;r[։jk1>*7DrJp hk#ўΤxt(mbSvFv e70(: 8!^ N8޳,(>5oQ1(/3љjnUqUлzt(kژ1漸;g&Bj98":})A0xgn} E c^Q0.b&.dU*#:t6(&]\(k53Tdavv),χG_`JN9jW5g.6a&$*al^.cZlSFr\j +rP " IEU9#:n~ (>Kat&B @0XUޜTep6Pg.,!tp͛S#b|X"q6k'7DKDlȩ^2bV}+Zu(U2.Ygc[LB]"NIEs>W_,*3]dU,MhѾ`BD>ZkϝSV9aGF_T0ZbnN 3i0ԲcwYk 3;~fJJk$'ZvƚSoRjnT5XeH@Jꈔڬ2 ŬNYpf#Bii{m|hk񾒬dMS3`6_4=Xs)X6T\F@1YDQީpUTf-3"vpiH)5='nWEs/%ة0d(0Sp!J5D4 w&bjpn/UŽРޭr ܠ B6`宨hۜњxR:.}-\R^#-XG@EZtzSr.YrL@ڌ܌Wΐֻ1$.lpܗFv.Yk 5^K܎;j(*:@HZH|m77-`lHޔw&jt`BHGޱS+?/XMN~l.>NqkmS@ԝDpk?-Fi:QJV<2K@%s)Ŕ„Xs VGl>v9Tedc!% BVZ83R *Qm +EYq[#: +/jL`u_c|H7Μ:Zg)#I m 5* uL +XpEHP{^9t իSrOpLV* \;eŒURzUҁ@Y5Q&\u }SB@i]^ёVb˟wҰ֐5@YNhJz>iJ ɯ~Oۏ~ &Ŭ=xxCt('k5:SS/*Ut? +_ OW?/kj=3= G/p tͷoJ@1=)"C a- X`#T1 4'叫dDwi۩`@1庻/'uVwE]: KcTQT h/NP{5 R UjʈWW]E-- aYK[Fw}OXXe ; NPp퀎-jsɚW}ew?:P3KZ +7`DwPSTC ͙.!T`3]~Qcj_!}y=}LL/zQn]×JPy?T C[bP@W0dC84hHZjJ:a QA +*0.mӢ{C3DQЁ@r5*SA:Hn)D9E٤TW1&,5@51`Kؐ[@ +冴[fP4ۭHs@FUEy73! ̰Z$ +!:!jkS[x4RArL/CҐ|ېI9 "x-٫*}GtRrP fnvh7U1hU1(ϝ*ѸWx: +(w`! 'cH* qkZgajb!eáhgŹ FvoDw1&*J8P6y;a pcgVZ`h}+=;U#p< rkɹƌ666-Dt:թzxL+y2%w€SrRZ¸{ * T w^)ES1@h 5BYM@ErpiY'=Q\-r 0PAjxX]9:9 # .oW AXQmTxm W += XV=/Cph8xXl-ca\Wl +=*ky,­E]8ٙ (8`$ P)f{!+Za됛dQE2RN摙l5{!:cR3?/ 3l_> +m`N +@t-_=T 7c0Ϡ[(^sdx)%!`(Ƃ+ƝlpCRI@!@=k \xFCW `(BH)c<΃ P:`@&Y Ib& +3Pv"B@$Ar*d!L^aX +Z +zB9x5Tub +3/f5(#rSKRErPȧ2"0G +x-з;[j\J [/N ^@hAroEKaB,<%Q\@8W6 Pn+=p EJ2, _R (|tZ*/`V\XVYhTF + GYݫ0=}{{e ++Va%4ӗ,5 dQnGn3N:$ |?Z72v'%`hiA +Wck/cL3RV!UڲH:?#>wTq:HK%{ɪ"PTD21D̼ozcH?`7 +|*5MPI 6y(0jd"쇉@Cr9=<sk]hcBi˲Av*쓻׈~Ԍ +ɲ 8eCXn՝R Ytp195W@:PpsH>?^rZ.lO>I9Y2_V3qjrFmRFqz_iN̈S׀͹F{魠sJm4\9k4w+X>A@:iT\NUޢ^ q ?XzoIr}?T&9WWs>93sa`DPQP * A x~ oUk֚>]~+($[#$#e2n4gf/_ҍEw#Cv +X5!QhzHcQwrDk c>Ԁ ? +!;|blϬ]ɶn9¾ m IЇ\G`"Bv/ Z)[)&M2iV,f7T + +@;< Ā>U`ǜM!BIhpQpGc\Ӛ@I1*Ej/4rp2!"R-삳 mabq8ȹ^,-4l *\$VFG\TX4mT :3*iRń&HӃBhYN\Z2a!7G܂u~h,eI!!ʈqNHi6>6{@/3eClzΣbCcJ0&]=AA~W A{`ߨg u%3:Fnr8!2AoCP0r aB \&H$jy n +L,4vFa%"PD ѭp䇙4D-*~{*RjPzC~L#Jv5qAADBC1*^q8mڄ}s؈08_襬 准B#;:lw#%XqI\h79FPӃ +(ѢP5r a* E]~ٚpR~0A QFn|ӨH ,HhiG.xN Fptw86@P +ΐi9S.2cI clR洏Jtg0mAeÜ^'2NԏF$B#nLk( &"f}t +@d|h|54 4 @P'7V8 ]nD*D:DN:=$gsȎah8BFEk|֒MAֳ{C~ u 6l)N39+?`CƀD0p.,@P6+5%2BeHtI:O )o0Dh_iK_#v|Fl+z/o )_ Kicbn߰?Ўlft0lE_k`GM +;}"/0#@P(5q#b1Nf"p:!gI 9V/{"Z}` Xʈ+ K@}a1"B,jPZRc䨛FCU?W Vm1ul,$ۡjI:dkCt%K+\auBz'ANz} ]jIO9h@>7慀T%@,@ubs~Dp@.W]J v'S?P}i: >sq mp [!@#^l)e+e} szyOHDŽ:Ȋq#=Nrr`2y``>H(0Ձa7]@0=FnހvQÐ\4 )PX+PPF) p/^+8Ш;=h  XMA/ &yLҘv,Ba𸛅3  )UH"Q΀ R,Kj(ma>v` 78`+v/xҋl~#~".%NH Qw@ +aũ"N ! +pHj+l:Ra.!iIGjC!NIO;)pGrxe0qc.PI@bep0Y`HHLN#)P( NUF_o9 FM*@,v7G27Ѱ;Yjhw7=phZ3{tlOr/)ͳZV E?4|QZo@a#c@#~bd ʑG܇iY=@Xߘ{ب+HpVӼ9ṑNygҷ4Wc͎WZj3N?o-邚XydpAg–Sx<f)=L$@zF\㰋洦%w/{:,eźl ([7@atڋhvڅ`ʇ@E >SSBkIX2>JZ]#N, }c`6 +SN~ n :H#cǜ /| \^aNGzcos`yB +`Mτiow#EnjoiȈ 4 Db Aa#,Cc(\ `PU/?ЂXl|+6XK*tZ)^ڿ7skKUg!U#&J#@NpТQ* fPl@kP峔=u_ a!#@X0xR@~H ՋI!X7bj +XHyXaOX,ĥ~ qYR!\! pQS@*otP 0(|Pʨu +SIB`dxhC! G&HZS_ܯWvb81 %rb rFBJ5iIzPTR>&( +2'&AT"B[Bt?0 <,'D&[^ \@Neco&%m"Rs9Jv +9R!Hd/6l"&@TVkA +fZb Dtl + q Y0`ԁDz0tDKb +T j>DSiR(f[OJ.od[Bb=uT(C AĐj;A. R~]?(x֍QQO"Xh&e F7AƖSD''47hylXkhMЫ]Zo%W)&$'XKh52ZYZ1%SF)5 R raXMHc!ڀ~b:^: 2 +)bM}>=/mSkcbfΎHz^,riΜ@|{0A\4P[BDc=Z7J.t"Q@;*LBIDu/o 6-7;e3Tt%@㑂0 +,fOҒA0qHM66ZsqJ-%2֑sJad̞^YRr@ɘdsUMA乱> ŅE!ʴH(2rD;O^ρp` o2dkTjB\/,#tbD{4s||X^V ]Q\sۨ1˚rU!QY;RڢVRv<퓁db- +'!ڤ" T6ȱCVXi5>g! 5I3rӭ\rb,$Cܢ8ܸ\"e.>%u'"zx#hE^5B|5.4A @+𖖍RS, +TƦQ_ӫFu nj!>*e6c}-lC & h)P^߼hoSfK-5O'ۅŹSfkMHl9\<؁* +mth>BLh*/ PIxhS*H|~H-g& -?pCgR3ʋ֖F0T213ՅF|Ǧ/\VS|ou0h6.].΅|XH\$=.'3Q87{GqÑ06Rd2lj` G##y ĉDrDܜ?e6zmEΉY97lfNě.L r/e.Wr֡L8.I śA8*ze%;* R]R}%7X6[xZPz}UNCI;\]6jBrP>M2p.`Qk_Qҳ#dkciZ0 (=~uuVϪپ`5R㒓Kv{bXy(.ɹLP}luLi{ǤT/ħ)[-je5;}"-)~ N[O{?e[CSsD5"aTQHP6WoҢϫ~k]fRZe_ٽ!ᬲcΩ{|1;fyry|Jrudw{ }CX$+NfzS͕۪3Gg7-VZG%G\u rk֧d#%F9V2{cjq:cv!Y +ȇV򳭥k"4Qck>Ц3P-}!V 3ֆY_vj{'޾ʛNە #!ޜϟ_9HH%(LC/t׮Lmz'lFʂ/1wӗh+L%RJ#X}Y)a! + W㇒HI:{6kˉ:cLG,G3*gTSM%;lO[zi8عv3 pCW/2kCL=vts%D+rY/.ա;wI%=OYR\k]-^5:{F8[U&6|u,ReSS% +x)V ū(f\ZItS\ +)BLB-,f8?u:38hhy<[Ib%45泯&i&{V^mƷ3_/p^>7~Ƶmv7~U"gťtp{{+'&N>à=đ#wɵOљ^HUU_8{k̝_=tyv4 Ojy15 @?!h~wg`cmpA`fy3+13Ա?_>)•TPwZf[ dl11ffչ#7W.Pfskp~De1w>OO +@5(E 3^\AkܔQܼۘr;vF-Q6꥽r͵sw<pP`.&RŹ+Kơ'/ܙ,p1!$E ++νZIAO!k"U)5j^''&VR]h.=~;x6`Dg;Ito; LH)!H ]߭.]JOV7I>ߝ>dZ~J|V5fS'X"zKv@L(5#;bt0Z6Qk/\4gI&a)Z]Mަ  +y̽lNd[7ק'I,Nl:۽t5VZV%5IkMZdZQvXJy0ۚ;5y^3J<]=@U*B-Xepqr8|uvj +4cRC^C4N':ysaFjE%:$[Rq1(U %\8QECB6%pvķl{8Li-.a&5F(FyRƥԣNk(ΥbAWJ +`„IEP2Lbr=l[5;%?$* A%0j˳YWm!#%8cVyHJP +S;n~8GM1q530w8Q-ۋk'4pfeU/RmfM|!a)D!6=Λhs13e#֊r&"%a\Kg11 cnMr +@AQhBx5YfhOF(VR)GEqZga!@?Ts9T+ `\P1-ٮLQG;| T#AT + A*a2kdb4?Y܃kS}PeOD$;[YԜ;jnrO&`dz("ZezqƼ4ũh֋`ޔDF:+ey4!aFPSޠdsQ.?'gv;J >Fsj +qxluT@$$JNŊCbvb1pKݭbMe8&z;C\)ьlW/t=md3go}dvb;G9*VչM=eZ0X!,Lk#?}J.-#Y̧;sj:TcVj*g09 >\qaRrm`IA捴oSє,ʹ&/jM%3r8 X` #re8vY$oX墝HrҬDsbsޅlWv rVh-3`35t2qV>_EL84@UfeFLx!HI +Ԗ^%3j+.8MFtKBЈ{̆rh#W^16#.V^J_HQhRsylc+wГ>ϿO/_w_>ְOtk?yQ]??$JJY63<4L}ڛjMn>v#/~w>O~& ƙjo׫A25 FɃ Q/"l:QLN5gS|{?w[{ݏ/_}ؕ{LE&R86ҕ=LgW֎ջo?ŧ믟އ>+<M-;@ø,ELP0eБ4d7c7sWȓ?8r1Qo_'6>s-׮?'_z?>|O_oxW 3b|=|Kׯ\Ǿ+{W̏_y퍷_ſ/|_6[tw%-S+:Kkn|_{;{߽gz_|Ǿ^؋@42VwjmgCN]o|?|'~'_z{87~ ɵ1&/ws?}W~>x/۝皫B''h(`uiӗ=SϾ|ӟzPn[?o7":?X,6()&|G7?|O^>?}೿>/<ܛOr&=A&Lif;u{o_w~|O?/?}<՝;\n) 8jtSL<ӮVz3k/O~}Wx[zȌsjKcS˛GϞxkw}_篼峿>w~~}cgĊSDpH,a&#'>t,ODU)VTt5s-'\8qԝw?{]@ӟ?'~G}џ^~k#ˋ"Tiw_Oo~×~__y?w}?tmZChȉV0/&O7~K?[|~?>p7Ӟ qO@@XUU-{GO}o}7y|? 8Ň}~?郷z >ek'Hkz,H:G{౟Wo}? |O_y~߾7zǾ7߳t`6\dBa edًϽ/C5׿_[g׮o: JfN:Z}zqne{s~y;+o+ +b1Jh(V3BMMMO9rܹGCu̕ۦl%*1bdT9z-{˥xr՛ջӇ>{噕#SR5ZL1rR;XY_x3OWz>O^k?ɋ/>z<~xwrYuD +}kHw !lT1^xg?}g3]={`0Y . g 51 J:]t&ֶ]<O;/npZ%d<~h3f۝Ed#/nl:w-^~ws_?g}9{Dҥ%3@!l6k$fJ=|'ާo~Gz۷G|ɹ|m=wl.aq6-Rwhx_Ϟ|s}=wwl6;}!b5 F +4+GleЛ\[eT [A\<ԑwpv8Ql #9PIKfT 0K'YQOC<%Qta HJE1d9^npH|7s{.NPGwt wc?,7KzxS$x*T2Y*Rݛ\\2\vrbSU Dn (oC"RrFRLDUNMMdMHAIRLvy0 q86gƓL6RPZ1_(yĉv5?;6ӓSJ}pyȨ!uOiF% \QM %6*`yvj)*\4,>ܐj6X9&$ 9ȁ Frcbwv9h0i\ɈIZxX>}VIM;엾<`:a ,BYx}.+K4#sh5323Dh,?)cNØD(hJsq̽>2 +ޒUV RCb"C'p8el4ًuA.RcA  Q aR݋yQ֏8QL4 ^'"yƨJ&egax NL)V!gs7 A0 x$ q!\qI?B4ɚ^sjg -9:G(#q6I55@e +a2G.az qzQ42\֜~筯)o("BOTd@(1UjT(<.늘In8jgsa9ѯ42fGH&b2'JnTMv25-ً' m~zfC#^8*Vp@4;o#Oe; jn%Ff0BnhӤP 9rugg0ސSq%VI2!LCcT$' qF]1w +!TvUJ̧3U*(XkwzY[Gcm|ͤxīT#= R>U¤.GEH-Q]W RLg\!;(nPMhHze\cLK—?|.SJnԅ1]+%TԬzh7<8vNW#(6A Y2$}z>D$K p0Ў_BLVgQs̅6vCЈ{ 饆GN ы82sZ~9l$e\Îm6'el+i=|սӢ*rzVL Ip )ɓfe P ژbJZE8eYaIk=MJe91юͺ4݉00HrӚca%#GQx)M17 +s9U%=HRu@#0v732Qla:!FD؁Z0 5LJ)\P%DiBwi<ZsqMRJC04q +&UJ3-U#vAvP 6HuⲜ#\U{ 89 .)DFEpoH #FqoXPt?He*)1&  + _T +znEqhOJ‘5BL6^۞9@qQQ6 +((l$T E;,`yl!rZ oZ=!cWp#aM#6{A:vdɣ1l޵靫>ځDhG.CRZ`+a9@gPErfʼn [_ALFJ$GL FMZj),)!$;x$Oi5)3n +..Y\PbM9d5N Gڃ@_YKKYnW$wX[w6t:e@IXwJNtXaJ cLfm0?0hך@x3dԅ; U\*ȩ0`e)ݿiطV1.EY-ُt4c2Jr:CchDx~Bmqm "0F +#㛷0j!Lˍ( a*'nl$lzxo2[KDE4*R1g/$¢k={jhzzCZ0t>HM7H[WbB P_qD5{`Q2Y%>eLDcjE--Hcmo&"f0>I܂_HW^s$e=ˆ-G#sP&Tw' e'O)<y%AQ!:hXqU0^DQ1_@8.TPLFjjZN0eĨ.L&H'q>:-|kCԫfM#L7Z4!RG~ɏZs3TB4:J錐{r,Y + +.?'#d \R\Gʘ.?hQP&y1Q b1JzWNLF7x' n@Fn )CWodT ǜ`I>5¼!W˜R4եTg7p!LƜ#D䋂֓ ?pD2Cr.3 +~#j4;zG; ûrLN4DrM~{L&e=ĚR fgbxsW@V@!:l.b4Xy>XCujN־z3}<)E:Kjٍ&J{VOgB!6ItZr8ntX O(Ɗ2ƥlQ1 Fi-.d&h'&XR`LɉOaas"F5F*Ĉ=$jUA-'<8'RXeV m1J-rƓR bs5)ve”sX" +о*fˏi_762eZ, Ѻd#f!s2 >"HX[e|2RӋK3Z~F/sq1;)姍ڊw1߾ާ;A\B>jz ' rMqJFy>L4)9$k=ڨr쩅lk1}|zaL4ЊO}cLza6XQ Xa6wxѨ yF.&Z2} Ψ 6]1 ũۊ8gZ+ML]\k 3($F >䵊|⸂^Z\ro1!v9l+?}"?s~G}:-H6HFA7Hm=BuǍA& +q?nZϋ +J ήRBr3T5"xmò+Gh4l(H^oe4|0oW(E,w[[;V` w A$^mKknDZ鎓8Nngs8йʜ=XS,TUڴyE "NJeDZR1`P + XdW@ ) 6;2<V5dsxއjrPѱm=FZShm+q>&#R… !')xyac67(Y1[8*dVƻ|B(KƖUYI4--3't(h֨X{7VBɭs^2P(uCC."F*۝FjRKB V.[3QHjk7A +%BD#.lw/Nvra\\sQŪv8Irb*5y|Z{P]'d P JMI<>a{qdq||?;sn|QgIi<)@΁k}BWr}T3B%O@D +RƏN?x4}T߻2 >?6=C2(SJ.`8 x=3jn [.vÔx=x8/Tn RVcNcKիOn~6KiB$jɓjj +P +#E=5]9 խYX1JkJf@b%&\jTh3O\yٸjkgVaPVq˯N&tқaC!b3(7{a$k@yjRQX:}Ot!Hl}-͎ii:g#5j2xPtnmr iJW . +Wk g_?g#TLJtAE P\.qj;vZSZv3a1sppܫ/_3.<<`F+!@DhcPn$4d@RЋfq 1RD;^^A n j'adĆmSzb3Lx @F)B`d N-@ )\.4cĔ5RSRfiTad:vpF\{0[RS.26 +BxqyA R>2'h> =q%FE=;_8e0??zs!R ̉tsoS?(Ο +$P/67x8z߇Oˁ0\ *,>|WGb!pd X3N1jmÄ# ?b-X @@N8?dqeLUI<A[QA7 q#YYʋ/  PŧI?J;0pUCѣv^.H*HF(˪MLdC]ЋBj:Fy'p XȂ !o@qy/$y>`dT dž}Dz9?eJV5lǏ+VsήåxѤPLΝ3^ux-H>Zo\#9!:Z|er4j+_=~ŇdZp;/OyG>&kYEs׃T 7 ?l/y_16*#ʧ&)Devbkhaw eB+:!A n> oN"G(SMH} \X={훀UuCaUùy*6$Eh=)fediNY%3rٓO2gpd ѝcr4a]HċЉUvb`_#1.7q6BP㳔.4)#f!2 +y:To_ٹgY->>)eL޷)!La9̪e|ypw?96<{Do@èJ92JdP(?*''C=@ .Y5è9)0>B |t$rlʤQ-O탤D}`|Pڙ=F49zj$!dqrxwd6gqi{a 0V,\ 6 + 'p"-WpA  +9Aّ p*c"Vuj⤚iǯSX%i~wa#P(V -,0,0I_p@J#F*FB{?uynޭ WW_TB ZSx|tZzVn2^"ŸCQC59:Vh}3+loGrR3"ǛF N[o؍j5\Zjg <_.v+sg +fǯ=ؘHٔ/ZD<`YWfw驥?՟ԨGƥ\H6?;.n "DU~<J!;eiBhRuفPlρ:e#~~7EtUj1zGAu $\`Nّk+Za(.Z;rn!'vBZp~4JYF?Ew@pEO ōP\lF:u->DdSHue raIFbKj6YAis|RaBl>@9pC°?|KI:Tr{?OZ:E z~=mn6'N.J\1B#`z3 }lsb(Vf?zTKp*F+b +P-#(LFq&OeKCQ%1қm*0j #p6%#;-FJRJ3c}`Q2LpmM;T!HST⭡/0"mI@@J\@)+ƫ5NRbg:z璌69`b>j pN͂}^sBdEᆅ#}#}@BΆJ1F(EB.DjU>HwӧΙzB*I7X€ãԛUIg@0\ tp=Rck |Vy,᚞`Bu`P1BL!m'-usfB+ p̈_$7L ϯz9qqaZ:vvm1^ٍKI@~Lڹxa y  |4AAyc{u:F6 Drl2K[7JoQZ ,*GS+5 S8233fjS@vl^zpzQBT^T{^wq:640R)Sa;F1(]8Q_@A0 ~ڃN7K9AGU^NRW o=[;A4NJEU@!|@iF,1bQM!L"x%>ozQ? @& G܄ , F]rрe0'b[KF{^@ Ů)!6kN?;P4AZɑ:m?@)| _t5<;gK7,- +ئcm^cɜċvgMD hhW֩[lpmYLw)4}{zo `v{"S_bdaRВNO{aezC'CP]@^׊reI38.Zzn~mAiB}&\99qi3]holH8L6$%75{uh1 ˉ.c& JU%M( jzbԁ0\SsN/^H^`a9(C&RgA*3+bӻ/IN]^7ps~,BuBHA +P Pe# 9$%#/8`ERs4.# UH%FJjF/;ӛ?ş6N7kҼ;.Ʀ{7!2V.Ad9y'${=$gYڻ9%%=mfۧ[7O=>qlg4wU-1\' Dp!C9)6<ü>.AAB ǭRJAji`r 5Q.^i͜W chZR@`{ ' J{l AB< +a6٘mϝȠPVeERHV 38͗g%;8NpB `z4=>3/˙D,Bd|~?}&7ew3+H>+UNkZJ2Pt# u ,#5!D  FuOnabTgN?pdY</.]P {C#7BPNXqC\8\/&qĎ;<,,o5? cZ2rZeK[LJrDc jAZňMk|$)4pw{>ڌ'g4 axd8`nkrVήn^OmVEͤ'+s秏9J3œ|!>J^)匶`F0e'@dJ*1*@Qin SBkn +">ݘgU{- TPyA,(kB(oCN/R|OG}Yl1P.'>k)^T֗x~GTzֱw1Q/fHO]۽wõU1Ap M( +|xdD.7U 7AhFpg}Fp HpzENpeR8D(cfKOY>v56Sf 3!h) nkvǃ+@]. _rXB0W,6WO\19i kg-m_cZo?;r̲FZy祩mԅه[T'Ŧ|}SM\ jN x"36δW(1 zfBMζV7O?ܻ ׊ +pQ'^G@DŊFңmLx A;ETzpa91e⍝Dsdk\|׊*VJ+6 !W*STqq@`2Xݝw&{_\ᦏJqf+Zݜ}&[œn}uTOEvy(&p6 {:`%r|&=L޼<I7i8ep 3) ͺ )fG!_@hX/fp|\ywaԂlB^!Z'j3ÅE\.@ +aK9JkJp~3k~0>8&2%/rtjW6ssJ"Zu+9uc +egP kΠ2L=y+S]GQH Nj3?-ٹPe 7߽5e4Lek{y%^7?LxlV0Mzw#&ILjRsBNLfD:FО+|x\vpo=86k!iR(]T2EPsOzs`UP l!u endstream endobj 110 0 obj <>stream +^z/.lZt(  dz+Fz l'o^ ޏ Pa*tgWީ._Zk߹sxó7jo\ kӳgꆖ[ظqx\(<N8a52k2QJ+0FI/̘[7yl.fO'vF}p=^ 6Tŕ3 Dwݍ9.<YIcB R$Tx閧4/n< @3|J$%P>ȱy*AʸRD8%I&)Pݐ5Pt3eZ@[r:q٪ik}' +ZBl&Ћ[ܸpqubkЎ{` ҂rө=J_̕67C Dk7OwҺG?^߹0R, `k;氓 )0hQ3j˩ 7_Gd;8gn[;O*B +F}92rcw1~Dǹ;:R<3ts#(1L.H&%h#Mĕdʝ!({zqfv/qg'_ݾꗓD).xhd'mFtN-R<4"5kvv%bC&rrliitȇ5bcrnÆa9lcv|rŁD| +yDʒf7QLkU0AxH ^3^]ܺٹ_[܏pLO +fq}ÉI>RuaLkV$% c J.^XaL!E[>7aRsqoηV/oOo^ r(Ja;2gV3եHf z&z`l4LPѢA_W5ϙET(0+=}ozKBΏEr! =F%9R΋vhqR4ۏ޺rmVc_-LbPOMɸw]/Oal|І dVř(c>\_ڐ/ (x(5IN9>Yf`rt0Z6V +S噃UQ|k}v̶U^ 偒5c4-).!ͅC3R%]^ +*-91ve{sK+jv69q"\^O4v[wټ^ܾ|j~!>v/~kՉOÛ%:AϥTش//(͟OKb}t{.7yƓO7νR?o@AyeC.Du4"{&59"$Lo@pyyw@qâ72'/UfNYDSBpb-( #t;{wiAAsFN괜Ĺѣ]}#а{YbƆ1..̇K+I3͕rw/RAχXu1K׃ؖwfF X!;:)I31>lƘYY rg?;;kO<ؾ\sUUDX_<8@IMsU=7)*;oԌ .>MV:ݭ7←ɱ~cܽ~|?ɯNŅPqANj[7w7P+mΞ +fݰ؄_1rs(mt7([--M9V<cdxIK4P4upMͬ,4g{"tмƨ%lzlՊ^LÔ|kZq&k!R¥H( ao^`,Eks8V\ˎ-;0֨0e Djl.BTDȋk>k6I)KI$q9\>l'I!.*^9Ȁr?pzy`7Y&L{o6nH1_ťlr0jPz\INI,XcE\ظ b>I#Iߠ3Y 3ۛk +&W[L+vj,̚!.jfJ2ʌ9Y_1r V;[T=>}=5Z?8u^?=Kݾxzu^nϟ>yڭ; :&ĭD֓w6.1pI9Gna,dD54{c\;hc8-s̭_G PxD`Xm溧mxIerݯ@TE{SW+S@t6J|l,&(8 V˂s &D|8Ce59 a:P*$z +B8x=uf;g,&5֨a: Fj"f +\aȁSRc@.8 6. Ͽ9FZvV}U"MwPW4!F~aC hu#R`#UDM9^󡢞LJ)1NñO}ꝐHrI1|4 1N 7cx33Gʠ:QJ*Y a +oj~ݛMi%6Nf+KQ@.o% ΄4-Jq^KfҘY+wʙUO,MNz{b*8JDQPSݫƊ5hJI!ґBq|,XR SAA.;Y=yWKK|R('\Auh})Ϝ߽nK%Wg9q;irs_[ 27qr+X$\}#]]96 A67 eA]/~jLy7v? c+w]yN8|Ё HOPA بa;wƥk}wlG!U A.ֻ;^"cZ>7@' t0:G\s!!%ڑZ{0}CvJOLC^i͸[ +׋Ns XH{1Ps30ʷ@3hٹ]Z*m8lD !>lPa8ڄW`|B>>DES +.@Xiதh.^ڤvjZV'ntVf4;n(a1B #4 Q\ci>Ta\" *iH& 8d{7*H)FbɱbswCAykDɷ3+ =0#I8∓b(1! ʓ03HyY76'6q6Rsp5Y鸕I&ZYPpT"ƆAki3 cPXmtKkT׍z{69-/RL~fiPT?BԊN7t!tca`G,D)pG?=9֬^y^hQ! $د qY.ȏ-z ^[AuyPI@9|M*EZt{7|T٥ J1$~q!+d-ddVlml|0C  T0#6f{+1A #Ξ!(]n O3loNzl<)NAP`%)P$/*q͵( Ji(BE^͂qx٘q@00"r-`(io *'p!  ++Pt(78\(EqblqାaDob0!yR}ҌWS7@u1B!L(0MZ+zB H6#:҆Ӎa,Ҋ +ŅBT8Z[?ypɑb"+ +e%FQ:#fQT 陃 ц`ʀpa:A݉ H(&<!?E?nrY +X[AT3p ^xUéLeQ!ph)Б,Z0aDp 6\a^&#R)90|bI +cU䚞\F <^/92z+g8cN>q`LyI8KR@r %dFCQ(*PRN!-ha -C2p^}Ȉˎ9Ƞg1A.&hߋ}pPB#flE=^{IohE\D艃kO? Uo D)xV̓/Pa}ۻ.O0+$մ 'Y cB̳b)5Ex{=.I`d\0p"E%+2׀4h +(1u?jF=IZ`Z6sWpA[YKk݃{8dgƸx*E^za ߍdm D(!VhӓУALRP)TDF*$] < R) +n/Oe\Iadc^/r'D@ pv~_2r6^) #=>j=<ⷍQDA0qӼcTxưX.Adlѣ#}9>2iwF>'-y|&)iPnMadfGa2(P=>9yRR&h7iY DJˍH2CK*q4 G Hv $ /b45aqc*W/V#ق+܈5T=drlZ[޸u>0lC#GMMW@y ZMEdyF~3IRY:8]."QE ( ;~0OUizHEb V'$8J#jՑd*=i! G힠҉Q:ѴX֙[4  PBB)3^d8΅sծfU./h IKd|EE2) ,Oi*l@H4bDt ,'s\&/劉Q0P<=;LtWn'_]K#$ $ije't1Y -GN-Fo_/vgsv|d +pA-"RDnJW2KvΟZ}y_{VIFuEahF/{}OD-7DS dIZd4cC^#^E{31?v{ٵw_>ޝ?[/^&4y\n9" YJ5pvѸ]8?߼}z|u?;xׯ`>ϮO}zsv#㘀9K71VZLx,V<]JO.l՞=˷?݇o|XGG] 9UI5Y监vx|kO?~t^v;H2wF$ 1DWq{r+t͗|מY_}xO/ZEQDҒ{H8o~]x/3կ?{'wOg߽YuߙF.4afd^W]}tTG/ٟt;'OvQ  A8 <$dvqE衅y~%un-?:h=87ʉ^yэl"DyUlnPח8[Ͽy'|6*˂$dpp+/ʢ~'_z/?/|_?o]\^&@x,5֖K[g.~x?_z~~?W~?=g{|!p|Shʥ{ݯ??YW_}on|owo?}'FY `pB["7[Qo;l?ߥlY"/)`'?{uן^_>ϞOOt*M„ĪW:ijcw߻\/^{W~OKݩw%{#U9>{qs9R{6 +g[W//^'ɟ>RܜIi' !5-81\晅rKscGo3x7_^ǯ_gWy|?~ҟ|:xZφp#4EoÍ5NMk7ֵ;kgs?/{ǿ|ϿxmoxmVȜJ8U^:Q}W7WzԻw׷K))'$LHdË%\V^olGջ>NO~ѹϞ>Y?X,+ A.f*XQ#Ik^\޾廇OmzDFf vV451^̇#uJ1h2Xn?{yw?|y/?߼]߿~?~q P8!!NMeD"&ńa'Wwgك7dݥN !iTl$1n +]HۛWO>8ռq<}~ן̽~a|r%DdE=jd3#c瞌laR|pW^tc4Nt"hBQT>ᢜŤgu5^dQlrcS3]_Yg7Orvp+Q0IMD QG|qcUd~ _~7G{|R[NC5㱀}(X椵bJ^>=ƍ;ٚ):?(Db}JU4X +X^[f33ƣÉ^ru{axu;Lf#~4aV +%HECQ@#<]rTŅfptquvl%5ONf"iS<"-`QűBfS:=O>ޣ_^խ/^^ܑ,n@s e҄w{}MFddw}kO6!p8<1G:q0 菠?n@AZt 䇬BVfT^{W/rDYU8DFqcٮu^XYoEOW_}x|oU pnmRjhޢ?:(bu[v8iGp*&ѲLGVF.VtlJSYXV!Z;lƓf}VܱNkz`Uot5HJ%[1_KaXpQ5/G7W'[ׇԹ7pWZ9[I[ 6駛TEHCuIo9.~lI:#~lMQD^14ǵ\K3=˙ V0AKՁP \02e:#Q:jٲU];j5X !͚ѨݎД5CVP`I"38{כO`U׮̐c˭{aTUs(H*Yc8 fRyU zݪ Η{ћ޻۝׍ƵG;"Ln7 …n8>Wm%W6/QNsR %oV%D;yC~4yD[3ڜ"ϙ}5vE[=UrX3]Z #4ɝb:KbqVoLVetgЌwIjbuTk9{tmw{@n["٦v +)9ko|U]~ugGJ0&܁o@un;NѺmRi|T{HVlCVDC{uOΟ/SGӫ?_=Ń_Զ_tvV;|"cJRK&ǼW9oO(8vᒏtG?ߺ㗿UGZNVzۄ޾ֽh=%!`(DE)2Тc~nw}Ltp ЇɽGzF>u&Dy*y͉W;石j[hBrO`S[G^ *U\ DL=~%:X %. bmz^s~s1$U0~{Lα/ޟXɬ򝄄N|uϝ˿(D3_xDTYu)"nAbrG?4w^ 6;Νj[4W9 s|7O [۸6V`pq^|^k6fz)mmF?Iޞ~WerZnOl5v7*·4|w:zUmV"y/dz^>b)Q#>إyr~%S)m)ܦԾdN/wƸr tZ〱(m-v޺T\%e癊osKm\ 48Ҙh=/Z~,ۺ{5 +ۿs[EhRL@9;#\b:Wo.ߙoGO$o`Tn|?|O$wؚ]V/Ic!"1. Im Zv~Z{Ӡ:}+x ig u||RnGpx{g2Nv6]ֈ4иZsyλ3=nP5zLp^_CNcg`^.ZEP9FVjSڞ*> +RZj1mѱڼgw/9&$Y5'ta]8 „*݁gcqՏAfa0'ϵƑ,m%GۀXA5iݽxZƞ7?(xk =z9{X9x7w/geUj{Z Y#0ά9^ϑ2 嚜5W"Z+!lDSBڰ@x%2:۰w̆!Ɛ )G(m .}jY2;G/1!z8kL>_?ן>|:&GJ]>|S[n=G?kKq̞Rcߛ]GUm`~>9Լɛģ?zKkmBj-ޛi6hBo}#Ogo`/EmzU;:{5vkTsyA,fĭ㷿j/o RzջgwNhq?$BՁ/Egw=ߧ;h|zNb;'@*ў{/~ywhuή((K:E:1G =Zhm=Nl] pz1:֑g>3w@ +I. +L1k';|Jw>Jk!O،9 F&a'fl5 +"jpt;(AԀ%b)89awLL9 +OAvI 6sƵcNy{Dp~gi{S{\0EwE4}ܐoU{'ɹ&7xYע8MhF똳Ǵ/3 ~%OW*t,i=zvEn$Fƒnes|<_\=6kjF\kvp~nkTU}|ikcP0Gٺ\fcJgvjzx?:|[;A0l=/=B߿տƋWY5í'=~ ri}֙!Zakw3R"vC-7/H0;ur"fA4KNdRy>=%wx?goMn8\D{f#j Ϙ]0׫ _0;&x׀b AXbD*l] +VˌGp9|KJi6@Ko=2dw7GJeOO]͖5 [2mV1oGw5Y?#6_\(^$+^Ѭ^R)JO(fr&1.-` vL ]!-NoI&%w/z`kr\8G@wZ=b;/>4:TP_kp7Ǽ fgŘSDlo +P#a %s)\b6Z;>鯞9KBV~6}]7a*T!D9m!|̦zGx.84`wuhK(+o5ʣ/ +,tr9y,FPQr),~ ;x3oGJ;Y1P_ hZ^L{awjV8;rpZGzm B;I<}?%.ҘuRm %}}I`!Bjۭ+Qn7g,beLY厠tSšCwҨ ΋8''0kzo{uZ<[Xro48EuT&oʄtO}hn:`Y0[Q)ptu{-`sl^bTŠqr<>MCz1JL]~pVs5>~KB"7zgzyBm\j܍s vy{* 1ZZ7@K#ܺ*,f*bWtb RF$|<-6!|-Z{a^ad>%ki#͊YڂU9!٨kV4J ]4A|wVO@TEǕ>٭sP+WΜV=FAN0b5{)g5"v SBǐRE4~+Zut=KKfw-ruO{o~~ tǕ]@BvAHS[͡tۛdR MJ֦ƗKN')9.afaY1d '67TcF"1 "6Sڂ-kE6F |*[wtJUiIjm%ZYcJ +ErWW(m蛔`n܁L{7g<|K[PdwPIkգ{o;߂aUr &,X{Y_+cO`[}\}0+yY&a ]B$_$aʋe-"F(exx4;[kFJh\XQ7k|E+c.聆M 6BRƘ'1}L*gAo:Rn["&Sr9}w9o+ShFeQԛG8w;H h 9Q7 x40RhU"ØCw/ELVI槵`^ N +J;^$ʔ V&Ho>XQ^o6g(%^x$:r/K!XUv;Bgy?oo._a~簵2kxtNqֵr<$^5fdw3 d"=i֜Th4 EܩBCd@2hBANs swն8 +z+t&A[T:Fm{'A-DN1xU~[EHڬxa|7+" XШbY~i~-r_Vă`|#{c-*nWbwKת@ܡf1.)gr,8/{6 l c0ֈ{nugYI!6+ʚ$`?h]Tnmޜ +ފRG8خB;d,Eȕ(PuOvxk.3,bRP@CiL`4H!VmR4=D!7vF*tΞQ:xAs힋`}cz2^ + @Vyi*٪ 'rpY6X6*Uhj2`C!l9Hy\lKN8~uN 48"Wgq{fnbK|f1Qfd)ə=F!B˕=z'ܰA2E $ +,X@ZF׮F>ET&z=ҢS`D]3Xhś#Pj /'Xɺ{/Fg?ӛE]J +.Qmҫvk_&FT%yTdڸHn8qE_2a$犋JJ&KzL b|fE&.g/H}X ~>M*-Iւ/>,Jb#(y.jtN¸Za&SUR[{YG_BFPpUHVd#s"(%6 ]rf|˝;Yf cRC,:$ 5d"/%̕TIerE6Vt2eEw# 0Y'eBp4S$Ǡ 8W'F w#ќxǝLIWd-R-r;YVn7&W(y7-V*%uTu-͇oxu.\!HyD*f2"\-˹>)DPe3,=J!xd|з"bK2 DU4'3ĥ*)cՠ.v(m$[0.&Ws@kܗ ER Mg~F'4̐Mc]dGW놿BZfcB&h_ΰ| E>ô`hΛen= ^dgDUp=S O +-\h TQ +X.mL$"o%\z+yHn +6*4g+{PHIP>[AqJ!dfxۆ;Fz1)Ip6)`krj9ԾwWd62ZF(UQb'KE]4nf,:P1J{wSfM`)P dHv]˱يʩ}3!y\T\p=ܣ5^V?ff\"2T%vqIFdp<]!C*>emC7̩Z?/H0,e_a +>~oA^E Xm|rXu+Z|>z6$Rֽ`;%jofMHPtq{LІ` Ot^ʕ4BH K **T^*S` 1Jw7 nH>*/Dq;] "H"h2=oҌT=  DłolŀQa*1@l t͖)12iّEuAYkT:}}V5*@ice}Hc8s@Uxtn@:$_e؄&=0ԨXVr'RPyj~i-{IwZ?d C0yԦ!ć@co?$ʨaAQgh\ +Iө@QRm4o}|rR:U0ltHL3H>4P tI&#F +_ː9­-\]fId3֫:JYђ­J{0+(!-W8pe-aZ'y_KN *[#PکU^ e1_p6A-B'KT6OJط߄]DpJ E he,bj{{( a7&o,N!x1Վih͠LۨT@1gm cX'7~2C!01D䮶<?0v7flYTb]n!| Gӻ ) I- @ XNteaӾTJ=p@A*E4vsZ$l9XX ZȖD;|`-/,A[8 .W1Rz(9v=Mg +ʝMԯGeH%Y,jHpg$9̼d2bLp0ʃ 6j-͊3 V/A_C/yY?S dmBzA\+mcn 9_ހx)PQ'dU_gؘ-Z(߭4j<1a:mo>,w\ZJU`dm9ܗ=0 Y dLr&XQ@(銒t(pֈX4iFϔ"&Ԍv>SkS@B:"hZ6oEg3`/ X)>H|@ +T N0c(fRP p&AcDvR@<0κct֖AkSt;ӝ,6ׅ`h Pdî+!&ϝT6|S%HڞT;T#5HAQ} ¬"j@=E\+Hw>:%iMJw`_0!<&yE79 + +*{*q1-ڜƈbJOե7<}=>ZkFs$Bfb| \#1.C' .ҖV]JC[`?Gي-[7 $P,5ʰjZ#x;:!j%;[1-8{HeĻiWd҃:j#ɲ3vdsGTɛۇCkX(kU{41&>)ќll +ɕD uQ Rrk>5Jbk(y{Z0fUzi]/*.%4! G8?,qqN)5Dl(K['؆Z˲SUƺŽD͒{܏K(cT3e f(7YDWsZh14#q)1JW@y^k2TA]]bqg\][qrX@I-c\G88GBL2.ʚS-ջe9M:)ȇ*ƅ# TɼgdPt<1.2^: :+ +xFz(}@մjJoR8\AJ4Xr;z|Ȃ +' h̑zb6n|$+@kK!=Tj "wd2fFd L y->);yD83-<7;elQj6eNVРr3*%A :;Ofgf2SOcҡ-V=ZْLJ-SRmL# *,-APqg+\(5^ƏIkQQPGj2H\j$7 M9o!xj@d Gy3nΝQ$c3.ڷW%hE`9D&$|9Jx`4Q(UQn|ru'EgQtFo~W~˸;)ͼ|HA*R.&.&Wh$eZj+ά'YBoC=>ɓuP"apt\ym_KeMHYQsTu+]R4Qss1ڧUU.}rXỤė5cψݟm~ x .Yk, h9P窶9k s;.WE2Vrv U:wSԭX,2}\S\l?Mm31U9΅eT0B]poycrf  +iƃ&w";#*1gk@]ޙf?4:sƔA IF~ {B,X-[-BRG|O9W0(R +nwAqNyCN åsYN&WYw;W` Sji>b|uQtGbv)܏I)U #(kieFQ|OJSt6塅ކy_ W&.5ŌnfODzOSV0E#A)4h1dr/$4"t60yVBJTQHʄ\\S[@)VӴC51-dh?ܣ*րp|/%8(B>#,!i0`h%"-8=MeIFe-$p,Zk l6ӄdr;huc6ːE{KOf +Q0]\'1SFn{C͙)k42| nhyk%o'0%ʜZS˼=%>?5huB,(EDoi@J×C\,`|A5|@ 1nv)a|"R ~TD Ѵ +͆-*\[?. j>9;ԫ;uOYC ;TLςmuJ|z|/<\D},fP UQ=-;u!tڪ3R!.=Cb$ɪ-}Hcs@U4fT^PB[#LRv Ig fa&wd/%on=b^ϋ:ӏzqaJv}^,O7Q+nȪr D!ج˄r/SZOgNe[{hnkn+2kѢa|toEBsJM˯)H5?b HHuCZnB8$ ^A,&`xQVQr#)?S饝F_[)#]4ʛ7%hc\j T&ۑ8Fjr$)LʠєKkI(pONt㘝$^6n0UIRnr}*56$<"KqEh-"JCki2u'?5@:QGab`CNjsqH88JNTTDV)\ +!\(O !UƚC5bxAp9׀aj΁һ +[US`>#j[0z!-g'?ʕ^z /"rr݉lx\5[P@*KuΜ _p;5VrI҇P݉N9{g;W' ڦ^X'~B\=xIu'>X^k)JVRT6E҄9dN(]q'{^2CjD})GZ*Ldo]rIFgHBH:=>A IB"#ԂEBR#f6ܘKO)~Ԁܲ[߮x-L8#S_}p+T3$"1/VzNʧ<)7D';C +9ŵrPG~Qm<:@W~koed!8v +0$0@ÿc><$)iF5JneXFL @\-Yk) (R| ҆"BR\$*H,KfWHRF!I_5@b i .}R,(DS|ʐ.|L$%1H' ~6r0CB+Jh*,ViKdt~"EI8\V ?)1qTZBRP)Qತ\@T*0* z)K?Y _:*9+S̉8-o%^*z79N-h^ +]9;"$)n-# 57H&ȵBg7Af}Nm6pV2W6leRi ~ԏ{ MRaLFTvFu'Ŗ ԶӛQH4,nԦu쁸C +σَ{9ZIXć,r9aDH.{˜Eq?r@&Pr97fi¥2\BXH+Pt0U11 +g!ɃfĪbҖC(РO +U/Ða`ASb93;80pG6 #%Kix 4Dp-&K $Z3Rc`(6O^OZ4%FpSF[ñI3ƅ(&FCMbBF瀲|B,#߇^r#\V&•˪ ,7+(m=A4FR!+10s3ސow9iMTQ^G2D6RmJD ֥.2z> H0`&.ZҊ&-aIIUTJ)B. N*J Wy)3ĕ"\>\j|"Qkم]hBNfdD2AI Y))PɌ6%KqBH.T@R $)!φV +3YsPI^w 8pjk4Ꮄsatc 霬3\:sAl'p]F=`gFh%P|/Ȁl|S"$9 è¿@dxh +032iTXz8>0D6*nc-h 4aj! 2r>D)(Պl 4g(;4B~'Z 0cl5Dk7ep>0֔Ҳ .X8a@\.)E"$8+ZiwGX0U岬ٷjM!t`"'+1-H3YhS\ +1܈d 1HJyS\'H͞Yە ނ8iKN_' ڛK ^NkB)btإ8ua! !8GCIF$-'1gԐŹxSnAa +R* T i !ϳR@ ӄKqVR.x~ ALa6xXN6$#0p. ҆ +6cmB5>0OSArdFY1s12d ^8Rzv ` `k$ +H3@5w@06J  + GeYù>0 14Nz4*B 7yd *QǢ;!Y^N2fjM VqEg76(BJ!BoL,C+;3@*d4KVcH|)7 "UX.+D~bV.c4Qʷ|?;LАKe6Xe\*p#< %V|PbSDr Ke@0.f(¢,i>/C $>$`(Rc)i.ΧI>$e!` _)!tVq`,҄ђS1IЬX)'QpwN/@G Aעi1ȑѐJBc+? + (;G`$ JRgc>NINp l-aRw/n&f511PCpWsNR}rʚK1HvZǜ[>yyB:n В!T7^?I0:T7k糽+֨hVLf~Q K^7kH8EZm\;IsHRC;׍leOB@mͥ[;4^FҞO_XDofAL:pοAt"# 1Po!`h >=H \CAt`iC6ú $Fp" z՝[.'!#=: f60{[lޣ"$"Δp* ˊ9ŪZsk9ͨ95z#Xi/b'0ne] #1uNpQBRp" lqIqD#*Zh\&P:Hp +p6GyPt xu1`?'^S-f*!( ʍo*EP.3RU6Zvk3V'j^ 4F-{u\B1_JcPIҊl?P벻d2r %9 +0KIʜKRhjŕ!Ko~9#ɹvq"DjFaY 4p. +α񽏥\ӴO%q㨄>43qM{& + +.'T$PUJnT8`&3@,`#]΅3JGRHUQBpֳ*d^/H@ID5@pL#JvI* "XI!,x4vz&\a0BW\م8p{^)L@Y8m^6`I{aLɪ־y6I N )4NhdMN8鑒p06Q z ݂= WŖUv}\,2r +i rAVx* <|yW 1!3Rj.u-wڙlbR% Vp!J:4\ +.Rg@IQ{ 4T@UB h/Bal |5 #K:)k!b|8 b <7Xc@UJ=M!"Bj>A ^l!xZzMp`4E HI-^m#i%ZRbUu) \0Si1\.@.q `[7լ>U }RX<)@ʻ@%Kg$%ì!]PM,0f<#SISQA'Qj~n\B4 +~2 Ddeʼ2J!&-V@g*E:AULg$+;~/J8)f |8&[=əj3"Hly=AjqLATs+M[aaXU` S½p j0>-#N2\.( IE<= Rd>IvYR)r[D|*JZ$xsv7Ip +VS!s@_ gbB$XNRJ7õ%-@K y@Т#9 危FiZ-(8jJh B6;.rNA¹/7C8TCl4%GS +BdiY.A?&Hp }B:RnWlW 1Ɣ gIL{h')J2\j3@_ID50Ƞ)K FB9@ #b\N:&ix@R]Ȑ*P+j‰Ŕ"ie,>lFT$] %˅?#F3"2ZZKƝ $ۑ>TUkE,Bl r<%iXyыi[H$n2XJ6HC+QT0-[A2W $9rp^chO6/X>Aѩ&17Rqڏp/ i(\-xĤH +)TOЎ,5qi' 8z0Dt348S08 pa$R@ #T3 +qΜOiQJ8g US]Z n\dPD7 rw +T + h8e 3f3Lv.\aH H|}NUFpu!IΟ(1:Q +WpVh@d" o@+`P = {|Tn.@L'0R%.%8J&P2 8L\(Z8IXDl(.T e9VIgòB`vO:фh96F!έ/$8IvK/Ҡ³g/AbP8:uR"$z +JHHN(QB4 WbId4AT;Wg3sQf>do.a"-<{!zir|?SLp)G1+ $V8g|~(H-JX/Ax*^p5$7R,p0rۡ7`Hn)r|vNGB9o, +pi\M=XE3D" }[$8.SXz0_-Vp=\59_# 3LhP&0Kh:0P鉤%DžNZEc -.vn h%5;!:v.Di8NB`t8H W8Æo UJe0+LZz'V. 16dVЩhJeA9q>-@L D V" $ n @|cO`/g! Xu9̅\p<1BBh0vp"8j/Wo!ڃT]qD. |֦iz%dPqqЂl@fY;=`%[]/pvGMDCA I (~ 1g}d('I;)GD+LSr>)6:i+Mќr"44\:?GwC`!قbLW:˅EXRE]ۗх8 +@8`3!4`BIҨIrK dH}Q *%r, %΃! /,T@fًqp lZ`?.Χx&⹳gCրGN?g`?p!>H'p&9TO_x([ݑU 3=?6++b.Fiͪ:#Ln"BbI1Z .V2\ҪfoZ]tb j.֘7.#(9^+cA +T/&kvv3R/rL,v_';ƥZwCP&Z)`bE˯}h$I7VȻˠsᤥ Ew"9/Z[CUŹR`4a}Q <;*lJff&!)  )pG)i8M( YP^ [L>4$g 8D*-@;@tpr(HjZ"yAi)CAUӸ:"Z,{QwqDH"2d!4,Y%: bMpѠi&JBnfB>. ZͲjC/ڥ#:CHR9\(U`0»#F &׭ HDSYe5 T%dG֊ h_k +Ш纇ֶ^vU2{zVӗ~wȻJǂ,V8oJyCA(emH(Rѥ64d>$2gAkmZk!Mp![ (v@WipJu >\(`BxYmz~*]Fk +v B#2($`C R-Qj 7Ps$:&HaCr9vyk-:Mi@r@&lJN( zN@`r#UEeSnU⨾|(B]D Fnqi>/zBZJ%|ofhSp!%nVKj~UV wXH}x`l!dzyթoJNS F09U +S9;0rM97&%w`W`Ibv(W]6*VVep嵶L9{*X ~+fyY-,r%,a6`[/]h4o^cvl@eWwھVԂUhrr +F-+.gۻ>pr Rt-08(q_AoGjMNySN8ȷ˼„Zs5 +i0h7ے7V +5svyr+; NsmW%znc %صY (/ +6W8c`Vm/7V[W#ƀfF8!׻\}yݳGۼֵ^Z*NNeY\i0V1ݰ+Vu|پv{w 7׋3}ΔڦZY$|D RYW^XZW^wȪm[tgj~(9Ԡ^Za + +6K廼ݖU +pTU+rFevvgy|lmOP/NzpOΓ/m_~(&~g׬ڵ5ݻ><` WP Sp!vн Ù%m88ʬ9Ɲ7#t^in<,^_:{޸lD49ɨhګ; 'kw>_QsRok`D@Ó`tLimmoP+w_xk/|EJ쬾|s[/}R}|rr?KK'/Fk Zoy1[8 *gY2`ewQzlR*pSŠ^تV/or3{;ťP\{VW쨴x0VZ=:!BsFۥ%G76ﻵ .ZYtnn 7W +٥asv9S\3kٝؿ6roiisk|釾4f10k Nj`:;>yV0ڽէ[[:{SW?lW=  ܴ1Y^m>mo?+=ES]<ڝ;׏_BF'xs'z^\7+UʋnkG+Y{J\$xS*yme1r~8Z ->eޛF;Gn&˽n)tyoOoZ`}շXO/;;rƭS(壇ӭ{GϽylAnk_0ٯ~ɞvP56ޜ~جn[?yRXv3/sɕ>wεg8궷S|8ΟoE-jLKg7O_im?4ꫝkE)7C*FGɯx+r)zckӳWow. n=zgu=n%mV *^ڝZrba |[^~2=jI6Lu0Z7j\pkǧ>$8gsQۄC\*S vm9;2+kGz\eH 7W^b$$<^5"+WyLr˥)qR e[҃8f\)l7#i6J<{(z'^Oէ2mԠ0ʦSYZ +Eַ{[;`k4 KfuRo3ѫ~勉y7(֯zmxR/=X]n7w3FF1! pU֯xݏ~G z/܄KrK}P]\^vZ@i]ېAV>{^t2{ƢׅAk/8)nW +]Bts҉][$UouziApZiémBB@kt V k?l=mZ|擏 3Za,[;bI:m*״fy9ˬNӺW;ZYB+nOuza|2I!(9mHjZ&<#X)+K-zKILNbZwЙ,HGеr moAxq@^&Ւ]쾰xxqe|EtFXN 4sݕkoaP֊R#`j{XP߉"B,LWPҊ49Gn4 V6jP}?Լd8tzP{ Z%Kr*D~}uo{w/.]grͷ7ҬؐfH.t\{٪6zgo>.Th3ʎlvթNPmr|tnםyE"̂e{Yu8 +щsZFV) [)ͩNrxm}?m^k"#q`ݕYqztg& +딃L/T_.tZk7t8ݬjVWs3C) vgz ^/ݥ(-d׮R$K)La"WS +2]A(6 k/+ڷß?۟~es[/ Yh7<];=qo//=/<Pp2qkKKlO_z#/ ÷>'+{'wo?J]V!b}]vjwջ{7?|g?__|S*uuʣ؜UҙdVӄjae\9<{G>o~|Ƨm.B8eZnʎ,p?ۘl^>}x;/K_?_eQvX y(Hqf)!ҎVKƍx2ڸ҇>KW#?W?w>Ο|嗿>{wa !S+nv¤UƕxG'w|ѫy_g?|M~]^C; XٮbwiHyٮn_y'oww>_hRp镻?_ݟ?_~_ڷN^EJNʕBlWoy7_y||[???>G+_'_|>/}~~g#o?OnZk@Wjɵ~o __}׾_~??w엾'H3A}1)%^bwiyƝGo|}}'?gg_kj8W^׳|{+\YZ;>8ħ?ů}@#_w?ӿ(CIT 8/YM4n6n='?|[~Kr]wf.7Su13uW33ۗqAH4b0J&YLIV̱cN<+ӓws@'??z[=/v)10p+z陗>g?_v.EHt,X(V-4SΩKK'_\џ/g_~X+,ct'Ϝpyw?/ynn#݅I+(4#lZruo{=Ͼ?_>/k~o|6Uz$j?Yj%rչݽ7η?/~?`8>_Ꮮz#3ByHZ\i.n<{wM~o~_◿|^lT\"+W4w_|ⷿ~|??/?O>y|;{CP6%1\1RP192;rt+/_;>}/OAp~ҫv7!".Dy%W/۲>{ǿ_O.~?/?~ǿ/?Cp m^KxG(Vш"g_淿?o@d~o'?};M,1_PC</FRjtµϽo|h~'?~釟|_]~s}딑dۦ{ZP(7ffWn߹܃{n_t\3 +rrN7$!Bbjts{_}o޺~+7 +f<'=w IxYTN\=dZ?}Wկ󫷾XJ +2L*=`C(t3VF3 g^}?wO>/W?qެ Ɯ^st8ER,_Z]=yܕkOܼy{w>_׿fI ; 7zY6KPF8I@%[jׯz ,]O'2@Sb\t`90B8DT91z|ql1l L8PS).Z A+bn#m^,@~.nūrbz…+Lナnfϕ0634M !]~CQ©Y`9@M|ZJ72=߸ t0-\2J L fPV=>ql5(k 2ˆFaL,W^ɵơq bp)[ 0*)gGlt&\+rj7(14Xɋqއ+@8` =@ KVH&M IP#M\J>XC-j|ڋGP0($`c;dCN7Saq; ?64(ޏZ^KR<0nlr>HǴĤ)sh")aG;F nF!$<@fsC#^kPbJr kSbMTLyI&*V6h>66()kP-*'F\L,3:!rJ$~cfJ84a'pb$8` KE NAd"3 3< n "ش1F|Xp bux?y$zXNjS-ڂ URPB2 V/I( T@|JUC$ؔ_c:F)%g”*Ly|KN@w;DFC~:J5FFe '\`:)uZɌ؂a¹ ]*!|ch6tpNpZf2|BryIW>%FZ&%>|d1a:1qjӞdIJGt|41ꄒ]FAQ`(°j%k]#˵N wEq2'e_mP CX23 EpI)^sAZ|\~IP&ԄaQxqC$`A9 πqxp?wQ2@r})yJ> 993"8ox=`&"XM=1k&g#EC.f铼^:pQj\iEҭOFSsfi!Rߌ5wI&ED,z/Wς"OIlX +~ܰy ńFm>76%c>B$@ߜ^NVV9-oj -Fn3=' BMqxDf!U+e^``r;ND&2"=TXr)#P-\ W9 "PN| vҊm(qR A/Fv&Y;qy,Pf!͜.̞w )n\}΍@sjZG594wT=Bg(#~n꒚[QaԵX3>(MYAQ +_^u)y1@[]rjٍ0:rGyE?l5"!X,UR)0f]3Hub"pf@)KҲ>Deց!mԍ]1moԎ:}hBeRlЫLM fpՌ7{30Xg@:q?Y9 hw"4䦌\SRbT+TPB'{zaLumn{sC!B:`9x}4]AyAT2zvƪ+BnjQd+aggH5 $- +gXpŒQ-7JNDo +s +{>&&¬VJTV8Y\2M1ަl^qj,΢@,vk7ѢfvoꓟOTz3ȔQdeA`r@2JN4}Ʊh-37r!Ɩ.M-yHVƛQrTc &GY@B܇7!>va>|hȃz#\M)Re21RαFIb 9с `^*Tcq8Px(`y55gqI%!2> ,LPrU`:YpA Kޠ*PRQM:;q ;(FRRRg./h ܎å,cTU:9w_R]RŚ{F~tS3gcm;N)02!fA8('*({XVIPe]JpurܘʙejFk^g!E=ٱyz3Z5_ڽW"N/A5Ԁ0&!:2X0Ro3Sz.|dԍݭ+܉UQf1o!7 ":4)%䆁WSPc6ܘ7[_+N%|$X !Ҥ-({0p*XTϴ6.}DeZ>w <=VluV>6!!ZŕJiMh/|wƄrQINάy4sTGݼ ԰HFmsI~!$| 81y0LAy"%@ 5sAr7殄 ++ޠ Jmqr6jL`2a!(FXj QWe&0Ce70k|x#;no ѓGƾ_- e NPR\w^q=,is>$"˩<圞hHG!qlnזׯ3R1io8 ꫰(gIoNiiRh),33d1F<6njha9\\3jv֪5)~jS'ߺkZgڽ7Yݭ\}3ll6'= g⥵A ;av +&̡ dK~2R\5Z_Jw}R +hPJ2K-p>RࣝAW&H'OZ( Vmt̽ 3dQ1 <Ԩ e^?r\tZm +i1ڷyYB܏k\,{ 'l^Snq./%6$crqR,FK4ȏҬf<22 1VrcAAQ;!3{.5  15zn̠k"RեB@eEPq2 A*:1kܱ1T{?7s&\3+/N_Z8}~;=yt|MH9ڦ\.BL\2fe@(P4`>ÚՑ'Ղt];];jOo\n/"M-Sչ#1=6$O&JNrĆjEHwR^HƹD`P[,^tOAjK??;b0en}ywPZB@>h64d'UT`*@5HGu&RҦ^}Rr([W$B7n6_P${c~`dhT@||j4h-j&whQ2 lr"Gu9T·g"L\c$<\λO&pWIhnyr pFέ͋/\z_NZ҅g~Yyg>UT]әG!dR/ƺ2E 0u $`FLHlWr}*3Xqus*\Y[,&#vҋZVSXXw(֒= $~Jfv*-O\}a~Zs `:{(oxf>aUV }ie&]pjva«oggϣr F/+иs\C?^ݏW'aO|lh"hvCXGn,N *&m^ۿ/gL1pMuĔ]bO3%JN@*yF-F*ʨl9<=>lNU/t#Y2(T SC%5Mi@ 1E-N˲Z^Ĭ 0r8,!G1; +L 1iZ6P CDѬN3tyyiZ #ݏ5vic"!6 +6/:Aj&e")Q* v?^J6*-T>Z|LNe#+srQ+vwl6g@LKTDzpC(=B!BWz'y`|H1 ci;@h 1`BL$ +e*G h)Y) + 97:gֵB CRKjr)\ZBm_;6 +5:LJ]@f{PDN>4Dj#U?h GFǛEb\Ə>a!/ۡVq %^RU"RHuM7}P.D}l5$!h$Z^9 JnھYp =[\:Y$jDru +{b $= ¤dG$CXid"ρa&. uFI{뽕ݫ GwooYd5BP[\߹F)/*F Ƈz0!z׆G}uL|A)NÊY(SRB + @*aBu%,6?ˇ`GsrC9_'Z*, ff7aN"PZ^yosS[̙¹Vq zokq˃ b:BAH M@ 7zno~s35m*\WSK֙oxⳬ;> +ũvu14g@ +O9D@q\MvX# 0Nl. l*PMB 5+;D}M/Ιxg_/Pȕ}ZBN)ੁ F bWj--*t֧vH4W J:0Ejkg7Ll̍<ΥXJJYn>)hTicNMBRJ h/ N+b0!PWW?/Y,4 + ӹv{pvb$[+= C# ;bwiv4~ sf蘏X-Ǫ[R2Vsj'XE*P1M +ӹX`܆X"T7'M 3RFA`DRY6sRXVmv:GcE`a/PwJ-D&|!LKB#1e󭳗Hx Q;ТXn6BnԆKj>[98bͧPkNZ09f{tP^wA&4ALJpCI%*/ǚ I)kфd+;̞]9zϜ2iP xjbx7n zA"\H m YroⳭ>ZgMpTd&Yu#eC `0)=8C <)Ykz'-yx7I@+UdPsf"4&憸tD#՞ݺD>q~)Q唞R+S@pE g7.$>D0݇q +hT`eO8:cL#y+7ٕݛAPґz,åpy].Z!΅ӳVc16G'<>":Fm(4|qLFV+UPָ'bn?(!}Bm`9}ԘubL -EKPw'yQeRZbXy(XTrxz8P\ʬT(rIAb6fP& '<(D8*倣E(m%>Axu+TbSWr̉&pp-Q"esã^é0H+%`|#`I)~zkK7^,.d1`YL//R.m%&vBc#dž=Qjd EנLLl,HB_ŧޝݛB  gNfkz*Nz%p*B}OQCzIPy.$no>x~yRwvA%;yEA? qX1e5?=beƱa 00&$gXI,k Xg!B`|ahIe]>c|ds`>`%#c .1r@s[{kr?X7@A<˜ RL`ALA"4xGIN9E?:}xF!\p)>;Xć*_XQ1׸aAwᡕP3C@V}0W_~Kw_Q\)Jsrf%ލz'nqVMBTR˙D{_+m,y`vo5gxfb\lgOy+?*On^|ZΫd5()B̒B^u@Mxp/K0&ek!8<<` y|rAD$qܷ"Z+gɥ jb_̝*ԖELrzʈ6A˯!cZwp|TLk+jMNTe@5%Z%&h+TJqe\(wƳmhcvw̽sVKTƥwl~ax /2UYϟ %.1>14\B F̍۱zthE6In<+|Ts\>|/>33B.D@eU|lO 8 ^0 +&:hOY^ͼD˳.'Cdk5d_MPZQ49jƧ !ۜ!=6H1ϛ-0|0KQ1T)k])Z5sQu ϵ ;_>PLt:2kTL_fO QDR猊 R@d+`#,-W?<*@Th i1RA"1ך?b͢SP- 4`3Q<Fv̆V] ^rxkONtLOKM3D(Ƨ4g=9-uRop:V=&E)\43ɍk V<Ʃz34b&'a\Ms>F|2UY1c5 ' 062py?|xĎ Z$|Ƥp崲hI1 *εW.z~czk-]uDbbҤg P0s^TjNi2KR;fEs'=w=0iAO.]sfV{wӧ1MxPODCbPO1}e)9hDLuvOlhdZ p:Y>7 7i/ MI!mzB8QTp6K~}\s*_~aP*0 +jnFN\ӏ*QNSryl%(PI5:ٝٿ~ujp~:͇;Z3|7OPJdGޏ\,REW`PIuN8 GƔ\v'l_ylg+)Z̶63'>&Yp3E(jsDEQ-[XPGAg|>-ouI9{|!Z'8z7 c|jr@f~a1> qq7b~17ultо +DB4tȅXU1@hB6l^814NLxF|ϝէD= p#0rS/N'ݳTQ.z 㘐E3Hkǂ 78Ng.\KvgoM]S2Wxo>aTǪ0wê8%Opqa5* )G#. +(נ33~·A._`UwVO;dg40x8k~,hbd}U\&T E \h1)Om9dpNwJNJfmkQbMJfaij/_5)ڈZoҡPn.X f@PFnՃS%_txE?4|(]#m-vv|Z9W0.DT@cvZOLԣc#@8IW)W8#>ع537-9eX_?׶3^\[ DmCHALzIqC8h:Yz5Fq.\V( !ެT}cqƹ;m,\*)DJCG8>uj| => r";6 Ja&730-X+O"Ԛ.W'gew=-7}pKOm ;^xjdKK'6sSvH99D%O4}VzDP%aݼZ ;jKgHkG-2P>lon*Y/g!J/X߬TҕQ!Xp|^ z{Op89%D.m^@ qmDL >Del%:~2)h# +>)t/yʝgŋ jy85ָwX`lm%v^ &87 b*XaPzoҙgګ¥eL,0Sϼ3 X<*ǙpDjhZq2w8-ϩF9t{~rmn/ZYt5̡dՀ4b<#eh>M@ >r>Pv7]s⩅]{5)rIl=婽~[H݄ҽ峯yQ1GP Mk_7ˋgHc|v6^[-Nn_uE3} MQcPшT`F4gԕxR +YaV@=qf e>L¥3r+t*Du. H; r0;z8 +:)$e9q4 c&fz9jjmBB({8%RVMta2 n n2DfvrgjJuG,vjl%'Q& +cS&%{5X44=> +"M2%Ecǜ^p &q7q./ͩip>bDRl{2shd+^[`rZ+&&=ÒYV}́\ %6M!`q6Q>\ +٥So<3q8Kx2^53.5O\… 溑tqW~)D +WWμtt}?7xWL/\+/V\x7/}磗ɋ~߹Rk=xԪkϟ=FTLH^-^3 klFIv܌h1ɇ /fVOdgKOhٵ Ä壓^XRB9=laoڼ%2-N/pW 'Jdp dBxB~"ryOZ7=>B AQ&HG|N*Y)5lq6%9$X5SRAhp&PΒv hSN H?ԨEMQjSzn LW)Nτ2jjZT-ͳjT2cJDQ%>f$!F*X(v4 hat"04&ÜRD)Y *;g6X34.FL db"Q]OnVԊTLM]չPv҃*ƚY0|Mk;O_?b7_<'ʳo]~jvƽ珮|x7;V/Z&[|ue؁H)Y!Ҷ󴚕3/|AֽȠ}QXHuY?js6gO闿R9B,@xTZ>wi6^:kNP!:Kԙepՙӹ.kU!&e!(EE-=90RZIvZ>xs)TRSnBDXZQ0|mNJR1HO]̆d7e0]0˙U☓8>"\pAܴI>9P/aɉW +;b ̐R`P.r*,j[f6k+d7=h&Oj1.?pUuOwAg 6 +6.y#gHܖ]6\T!vLjF(LZCi1ϠG6-ex3ȗb@$X:Y&=iBsn_LTrd;B]L ퟹzw:UfJ*(`t}zL=֋fh`&DIM7wclRkzf)9;|jӀAZ/g^?uʑN{_8dqr%(\2=| OZf]%S[;>Av-|홷!q.R2˫g_9Vv[yg|Q'>IFkqyx'ʣl>nGdž!/ӒK0Y=Ә猲i2Z ra@0LȈU +bRN"Ս̉ZntyxɐWyXOPd{O#|3>pn}UXwPy`|"B(u%;ypIWC6M?dXыn/Dxy ]Y/2|| h(rQR0ZFU'Wo/v,7ifzᔤʳb'u ѼscN̏q 0Hjk +"0lT9Zj TV};cKLSs9 4r"$AH(A%9r8۲G{9|s:--|"誷~Өt[vY4rݼl"М\-1 +<':ٕ ekր\oU@Sh^r3qٹtY܊]ڔ+4#LCi/Mnffe$%mؾ,o@PT؅#1*%,{/aQOs11zַMpBd׹{. L `I`'(ٵ+VUӸ)mj[Fh Sɲ଒ /IQjݥvu"mp:Fy*Bi֔\"Ŧ=MGM`dr}SsiPcR RR;֩9+Znr}58aP2NJ( M#7JVoFN +gxXcFb8N((**EҼehDhY8囏TBI9^^JۀOxνL! +e2iQ|/f*"…$ekML _S YѴfO.FT׍Dˎ~J;vq٫c|V"dQ5OP hLEHm(8Cdx5!` | N30&MHf5$A8Ða7JA84v wˆ'fHZ!*XWZD`q#, f`{T4D CCȉ2#լ! N\,AI̧ACLrAw0~t X33{0%!88#QF.KvHv8O3s!IP) &rSkO60JOT(S,ɖ0)-_Zf"x +ǃ8Er^M;_c||*^oTb7p"D"ͥaQҊU( I"I pEHQ +Lۭ5ړJ{4-QdD!rz&JI)c,ّaL3޼,ڵXANa\jm(M$C +gPƝE8!%d0F$!"pC(WڼZAH3Ƞdtd!ʊAS49UV<F GW>Gv$% 8h.)`Š.Xx0&|04T :'̓O!<3S3p4rW&iNA m`%C;A5q8Dx"0?y2@1/cϧqLIYQ $8̉Sg"@T +kg/]kPJ@+,RJ(b+}kb_g $9Yy/ˊ}$1q.%hbwy59ْ5kJqZiD$ hU-0DҌZtGesH(di,l<)>"X90 e%b\`I@gQs`KCZ<)Dc @i +(рQ$ -Xm|4 +:Zk{AӬBwqs. fa$dlPž "QxNED.V}+ZfT20VP ssIM*bYwT 'X%ZBuT,FN)&>{"#`qT׳LH] +_gUJ{UŮrj> YMNp0L"iK) t%g*mh",*x5% Fh4 z$ R)E +PC +ɨ*jf5o]Zy핇o=fUq +39st$4C$L41 GMU!I&Ȓ1Z +fgfO>qD`BAԉqa \JXS4P SBa"xXdYUz+U}]?wxnsy<{qsW] +A4JReKZc 9nYCV湋?ڛx?w { SQqB`:@ywz4\_4[a.6+ۣw.ܾ3yk/ܿyv*AX*ņB3P8}2x|0(HU苝oJHV|D`ɓ3dI'3lhM_Y5_xwo{Ͼ~g~K<].}X:PUWKZ9ۆnnwjU><mko?\ō?WJuO#(IJ*jdb//7ėTxK[}vo+EE =îfAE]0W:&jg1sv9sO}_~xᚮRh(!\ Ğ|$)PYCy%:)wgp_yx_|W?ꛯ>2kkGSWD *:W&KWH|?;= k7W_{~v6#X4M4x(6޻_{wۯm7˷~ͻʿꅍj7+iL 觨A_Vs$@ {Ks#l_}z_~x7vޟ~~G^?<|p$cfA*$QƓ4$FN~t{ڍ~wͷ˟?yϿx/n{e2)4R>?|8{G_~?z/曷_OQ{姖$* +/SOpAF3ך=^_~?}}~㽧ֵ"Y4?Xbo7.U +zsB_W>~x~|?~~G-Snق!p+fuW~+}ݷ~w~헟=hUG9lAޘ^i~ß{W>?￰(؜@S)kfɴJ*|@҃Ǘ +?K_{-}z˰KV"'d5JRur'/Ok޻O?|w\޺9zJss#p +Rq +'7ff[Քk|'߻矽z\fKD"ZvXX4ȁG.:V.vo]xZo{?yOo;{/l,lfbF}Μf'l޹;|_^K//(6㸬 o`3 -IK|->5bs^\K7nL9M5%Y$dzYnRk[ۻGx|o_í:k[D‰,fzimrt\ܹٝ5)7VW-~{/Mo]\f@a87Yi|:ʘPr^($ +Lӛ{̵s 7:`mZj~\r$ +%((RtԸ??|w/_|߿za뽻;vNISHB4Ql`@.c)ia)[K>ti\m{?x?~?zOo=lm+F.P|\ :o:L󤕺7)eFϵm/2K!+<9$$X6]us^SNV(CE4E9K(#I:'1^hlL7+4٫)WVAخ5kD( :+ēOQ2xbH_ijkI׽wY҃O-_K'iWfmr$eU/҂"fjjiFQk}7@ P3J0h!l(Y2J u ^T W%A s) ӾSv;\ ++X )zye 6de0yjv>dTTڂVZP0вLzgkS?ZnV,](ORke]8 cf#؉\ƅJYmCU +MѨK<~`c.̒$ra\t$gGѵJJލф|RjTlEu$NaGffRa*1Ȍ5t L3iXBqa=ː&QN$pS6D>bo%aiF5A;x"5| Ҵ h#Kqp뿚GBǔkiAT嬦+8а#z(x EHceKF 2!x[g)8?o'Ӂ|[ˌ Ģ{+x;i07}( X;=q$Jp҄D ^%`JeUm}ix> D.5y%-ٞ1n+#t&(iP'[UA/V).ZEհ|I/‰X K,@k O&i aˢ3r0>ԁ(#!["YLjeʤI/j'#$LZ6iC /٥`$BF(pZֈ$K|P. vfs+zD~Zmhw GZibsQH!9xet.oeFew. b_vn6ւ:ZJvOk r$.>q"vfC-[Jf9ڡ6"q2x20;Pِ|(GaWR"HKP8~3*eF+m\Y bkJTB)%M3yY\|(<>UYg)܈@ʺ8{,X/NF=i!:hv"8=QujqEyW`{6gv;m2;XAtV"Bn6JR)ERk-n4@k.G\8fSl8Ť1U05ƅF<%&Ӣd}gY}k]\@6@A*fF8kRk b-6΄(B03֦45%*(SED&΄ gHguTmJC,rVyH`v/ճVv\F *% +`EhL,iObL2˘CB2;Om \;wH{C WmS"J8Fe\sO,qi6GNS27݅YX2Y\('I_M"x)IC.)?c5C@+-\27!H#v$oHt.ܑYv{Nm'ysc\HR~LTHYIYRoވqǤމzpDQcvrqBn1"˻c=㨞8_g_Ƅr +#F vmԺ2Cy'Dl:(iQ' +ǹhJ'I/AeRSɉ"m|眒[D>stream +[1:ƼEYq1^îfa.-Xke8ڹ>@4V? xj]tSB(>Qش˛{wk׎_ 4ݵVF +g6; 럎qBu[r[zemXSp;fRPZ~mȹU&i('jqV"a7-n"B Sc{ͬPP0 SLȅvVD.SY;Rw΄ٸuHhwԝ?ί31!،;*T^{F.EQ3LgI/$lX/Dj֧ʆ(tL@:i/]op3 8ꥧ3QMP99n7#Lq̆—9-`\kg&ϑF,3ݳCPTNcSbrntE/2V4;Zq\ uLi0-@0L YdTIjcf}4x"xoԻ?k]޹uE9?SS^$Uk_^mrclKsxwϭW~︿ysYos0i͍g=eh\(mBaRb֩,]3;g\2Qt; +cWֺIajjO108Ti#bs&gֽ_}fv.Ogwkkn}пMkW},Nb3Sr\^y_^=~UuG9dfi<<_[R[jw8QGǨXEPKJq=ۿ=: *3K(F_k }pr{U/&rfkVn쳻7^+{ҚZM5R+-nqz &W&PRm%PJahN.n` Bd+ al;5:9>; ;-ޚX+Vm bYreR^ve ALzeK/U7!ߑrk@fJQ*[R~UnmXNyc#Ziݽíov׵#?woD1UZmޮݮ^/L1ϼ=پ)ʓ:wS0% [+mA9ѬLhpgRm̈́(*-M(/cięxJjԈ"V.Vק0XTGL"B VzS˫~{ a;W_Ru),X,C' ΅Jwnvp*ӽ`v2rGjQꫣzniP#OQqpIRf7: X \ɷv2!¦ صwls0 &-/ǘ($1dflJr.vDj [pMqz`V3axb(L!S?,qF{j`fya@92hRXzcHA}jD11N{ϬmqX7[ݍY[nb4he;rfl7hPvy4d7tRR+v}뜛`2|{K/-fi,^I^$M`Jq[RKS`~xj57g¨D$iVj2EfhS^P[P_`*-MdOt!TQt-љ5g@ƥ|懅Zw_[Mʮ)-9;1.+{Ol+s ϲ_ +. Ǥz{a|bgFypomo~ỽk 7;Vuarɥ/~dOA?6Ϫ?8&SI5 +m-bfnqx +S*id5F{{ achv +M\R\Uh%VO65se7/\}tj&}z*WVEVKeBi"m93OôʞYP u{ eB8}j&$V[;R|J)e:,L<66|U1F4lP_:(4瘹.g5#jG1L_%LSji}ir ,Qܤr;:|.Iep ]JxksӮӴI^39-0aDl|Ip֥x[`zgJj8yY3:xBbp'8„[K[aXMVkr "DKS~sXu!gdΨ2v/ Oj{bcrY[R_+I& ] YŋF) unFL(!ak* Bvкfi:b]ٺPtK͂ٺ;}J@bN0y΅JiW͖$n$CxRX>Cjm\iA|JFiY^<` );.o8&QTO% K+ӛ4j>OEՍLPJ,-^*JiE)X >7Js9$ +kڢMvFIE@VgVɢJ#I_>lW?nߊ6k9{ y# \fr]sp&@ )*24Xd9gtn~%IYZ+uV 1Z3DfW&?0 #@} N Ķ  B2$Bix!dNQHMQ]=ZeN`J5?}ZB +B{TVU@ULJY1 eU9 'ƺ9u9LqzO Ju|8qeBW.VֶX)˛'I ZSrsP!G5J@j3NeݮfjVv&\wCI2)^)-]Y) F{g+4%7y8~|xͭ{|~d + +4-B)%N0_rͣړKZaZ(jBJ>wv/WA#ʝMP'XBp@%[L +w8wz +PBsWq{Ss` %$O *A?f 1~,#@KPJUmUpA$iL)q-eW%ŹgX 3aHO2R8;$0:[k`:L RJ5{Ej$vGn&0DHZ Z3s` ! pNO9'@zя]ZKqhؼ> +\$"$A[Fj>k_a 7Ofh!%جQ(mZcsX0d*kl %Ĺ0&}=4fEh(|:M1xʔL%PG]P@xoyHn O8[ ]S+|f)Fj 36YLe*V\a|лaHղSe|Vc ţ->j8/eƬ[lL& 'ܲݹ/q"84[LGrR]8cI'w: A(?R@ O:5Z]ˏ@k4GIA=P4?FI xwM01HʰP&AzqK/c!I"rc6ʞAXJyۘ VkB)Jԅ5PkQT݁:+#\lbəLʙUY,];zsˀnPO70 -V-INOΡ}.Pn V4ӂ<P% IP5tAZLᙹ I.927!#I.I0qoZTl H Rdpɘ@w°vjOVe|\*RIFZy:DC)7j Wݽյ |c4,vEkYgNutuۅkK1yjsz9M..\>Žk_,/F-3@1ƀԇ%-v[rVjE0QV?7(Z ^^IP.4E/=Jfy;pshջ 4&O:{3c o0=~Z:ME{]s!bK"4(!YoFt +7St>9v2*oC 3*քYϴ1L(/р!\%1Lsn⫥)a8!AB IQtrଂR2!fXm@` ++z#)87Eڰk{Fg ;1t 4%t>_;KzecOKQEЕHS%\XQeEilbb>>M%wtg>[;ۍ-'7g?W\Q^8zALRg;l?3)TEt)9MD{pL:Hi-DR Ԅ +8 &Q<[_`!e`@bQR_LRn ѧ{0ѣx׫ bs)&S, ̞r0j j3E&1x(WoB̙eq1QZl D17PAIMDdܧuzDIԞ'fRs );kG^|[ $L,f2(1jGOJ +S$.-ěɺ5' L*sBcU)KcqNwげr_\ŴP R2AE$9%N~SfJjqG-S"l4 %hJ#h^Oq;@ &U)sE^b0Ň3~{T+).q" Pxzrux!$Kv+%c +D03!Ru:npxӌޟnVﵗ㸍\l(DɯBwQZVe-A'CD6l t0Ҧ[10X'C MyNJyDZ~mw:'Ɵ8>5$p +Iܙ )Rsc 0pZK@fqNj$_5yd($`=BWR 뗳 W$ggN$΄蹤ꤽd7 䙄zWO>dLF[Ib hjINC0+ôGdpq",<q l5mglm1MāxZX/ hFZkr,fw.yU3f~-ӻimDnę@<<2Ht%C>L8 +б( ݵZ091'ۍ@ 3Prk E)- %fts ]Qt#N +0y0N ̡|T2.[ ,/2(<+dKӭRbd$a5!"I^Պb:S."H79b&D}d(za \͙>Pʡl>fRO {5Ga +Wة*zソPKJJBzԳ>]dH|P B&n{3\AJbZȊ0PPrQ;T,g%O^tJ̮nˈz}^ˊo\4= "iYf%=n >]dofGFنgWã/oVGZ#i!~V2 OW؝A&PѪ?+f?om]4)9)eQN 8 ZQ`r  o ĕM&jr˜ ߏw}g&xC-9cuϝlY6 BJ6aJ>HNAws +7Xq๛Ž)A℠Fn>;u/r/39\zG0h3%^ J@,PwBZTW={v o^c:J5T8Av4JjjhuFeȰzA7EK:Zxfdt N[ E`)1SA$0ZRڀBQVH!lmmeG+!!8Tqy|J`c+9G;5LܒN5T2J-Z+܈Vf" 'or"D]TA:'HRP:}pi!B.aXpYmF ? &uggNvh2n!̞W,p No`N1%ӾL7Tqpڛ]bN^J ' p L $)u('#O0)K5@\l߻F/pL!!I4}19JIa՟׿InVg} xa\wxus:z/ɻJSwٷB 5k-< 4Fi} (oqWX}kaS1zU+Cj76Oq1633' \O'{ly`73\R\3kd7==9F!j +1Z.AлvfZ8[O5oNuH}NԙR `Ad8C1Tr.t 5om벙ZAf +ï[/m$gxcWw;1|k##R7n +fCTg6i ̞,6wC-ySZɋ;)"TFψA3{vgC4}ij- sCkZjLϡـn.O@95GRW Cq'?ղPu 4/+pS*]d>$MQ٧} ](hNǵR!4JB RTVZ@+>YIOMVinUDi~x|.ܫ( Qi*a5@͇WfܩqqM08\^ß 4|xdJ +9 VXѡ_ w/xֺ?ipswurjX0z#ڸd `k1ZGk+?-N_Hzn pM0|#٨vߴ;n80v^''..ТSiPP7VvLghW:I #X57j8Mq* *jm[G(><澕»1"rӵWezjG>yV91v%ŹMJom>@Lխ.DG*&*;/䋗7j/;s՞ARd㥛#N(!LaZ*BYն.k\.[uw^E(Zy7,6ٞ(v+j &(Bi}r~_oǏHF̞: ê}dGfz &ب>TMhCȩ[..$}hB"Fm O/ )WiEL5}+:stjhm({'B@f1 +U©-FJÙJT4g ]|\ou{,v^7ђ OH.̴ҍۻ|=Fy{Ddo ]mw A;ͶIރ L3ۙ`v\ҰbB ZfJu8vZ8 h@g))bVпqZ_Jr؟] +)&7ү~vIn]2ЕJYզA0a)#m4{[9 li7VJJȲiFUHLz;1bw,_#]Ի(hCYުUƫ0MtEM!RŽC0+=;բC @ڕ{F6_|ǯpZ' "CFhtǿ'ad?D{}M9{ 1̑d%sn%WNe0|)X:㔚N{/{+;UmK;WHy|9!ps$hhvl{7jN(1L @QY4~̿WP`bLoq9Z!hdlHP|M:#0 1(tPTnh g F:R'Ln6ZaKRm1FBYVB#>Fјc=,|sI!Y<~aG{u5{ͿpaLuԭ!%=yk[1[ :ŮzO_t Y ֬)eƭҶl_Y{:I- b!2[@V! H(V{n~U=( +OqBH Ds|\B=}ݞZǪ;ȈfJ7rM__M<@J W99vヸw[L\gV~fƇ ),z ƇN&wND/4\nv!+.pp[`Mx5ܑi9)7zzȕak||%[֘Z ⽂E?g8+MhM.#H+V(I f־9qZvvji+xg + zKZF` 6x^|e'g OkD)"Bts}i#Fl&~KHF3N!RUG7B7Wi)fE +ԁ6lY3l :⮷z''5&| +t*z4L]|h#*|Pc ?Whւ Ƿe4y&C(R Z19@؊7=yӯ^Ut?S݅`{T 'jmZJ=hA.Ṣ5Ru8{edVz=է誻}/G/߱挔;0L.+ɃլZA(]x_s~3z<~i$| +P!޼%'AOeXq-ĞZ0d2g\yošm[JCS*d|G=4["$b3Ti49Yճ#X KEoL>Y 0BФ`Ni*mr\BFvb!5~QZWnZ V%U} ~w/ޢJ Ǫ=';iI pv|G9VHڰ`әC_Uś[# 'saD8]|S8x'&%0.Ap  +؉NGj&GKICDfzbzSmKŜ/G G/!=U=uۺ.1)M6|+&i~kXӪYOFj^/!{ .K Zd˿_\7pX:0"b{~'$.xX2zί/)ٙ M6vބҹևeba`c33. ak+\gˏ)Y%LC۴BP+k]/CbTtVNYq* h~oxgD$7ʬଵ:|98Ax1非LjyYЁC- P3%awUv~G،1Sg?Zn1;pUeXR*zNq' !i>\~ٯ*<6䡕dž<ؐVcCZylC+ yh!<6䡕dž<ؐVcCZylC+ yh!<6䡕dž<ؐVcCZylC+ yh!<6䡕dž<ؐVcCZylC+ yh!<6䡕dž<ؐVcCZylC+ yh!<6䡕dž<ؐVcCZylC+ yh!<6䡕dž<ؐVV&%{I/wvg}l|c;ۻϲvMswru~扄P>O +Ohԑ<)>H|s_"'lQlB w e K%XvbSss$[-X*ڄS^$;09n`#K5:"F5B9W{Z=;U,hB1whgA8m(5|}s[s(ؑKVFv(sT5:K~.;Uw.kDySJexM4yg'fr"SRJ^e1rk%wX M1mPCɝx՛3FeнaICL+Wg}RJtkFkB׽._lH%NJ1%fCj}[sޚVhET+]ŞIքs^bZ-I1-6V.N]ݭҪ/ޑч|dN+MeW(8!d>>#LL(94U PJdhNy}"[s_r.è]=w:'znr~%kh;(ˏds?? b_IX5T쑝wS|3.޶ZݽԓΝZ7Wo]a6$~{A)E SbLQSBDI)W없_uiSc +a8Z3fz77`Dz ^g?;2 .0ޚVZ8yqzi1!*A9oۜ3 h>AiBRr^l# ֯zwalHqHiR]Ԕ:iB{UNh,bUv7>cnM[g+ql2Y1 EI +VHD b^)OsAԤN tqT{fG{-EڧЀzu8@~Fju 1bO`чA Zώ7n~H+-%wf1b.#o{|xceQ:f!H|XM-;+#:R=%{S-ZN\t~띩xO,/5g BX|'\Qw^NJUЉ3b3' *a|ZjSR}JiGatl:\q˄]!](1` 5&i)>ҴӠ}*iņ:ט΄ V9]54.H7`5E 0rF t9NiZh G)8j!]&hɅ>V3smg8nMWnEZ7+v356@F-piA @3ψmNI8k$vvNTo)ozO@i5Fr +ldJ5ctH's[ZA޿ӟ墿PCs^ײ?CH!㴱ٵ^qW큕6xfnu1۾LCA__2R*kHՙB@ +V\SȚBNp)%,hN=5 +vX@9-@; L8US34SoW7X S#rc5Xh@'cNBtEMRC&W!RèQ~ZbXˤא?%jӤC`vj<%g9BAvB{pV) 0CHdJi4* Ɛ͢=pukt6 f7!g%Ā:ui ~5N~oGۏ_ϯc̎.HKap )y BNOdw[~69.B7'2}DPj.(콑*/>.iB L*@H )sX1ť!y/#N Q%*)bfDUu}S6-wGB 9rF.5ݚP%mHR[]b0)DЫ~  +&\%)T^E hW @FU ,h~CEs`@Qek$LՀBlR']N +*pZx4=R!~D}`'G &഼9FB) +D呪rY);~:W 50|hZ`uap3z;I{*kE+#\@Ekk"g5oOh9j/^ԅ"5bpϔ|'wpӐ;等׽o[-ܢJzvSHI`2:p7k}`w +hefEi /Y~ztsweϲK;Vej b\lXTgAYҨaFg/:Ffr8KM,0A.3r7ҽ0#XPݺV",$/I[! +eC*d<:c-C_ i`kCNc~oS'I +)vYhA +Kٙ#!PA!8 Ckz;Iy0Tohi!*cuHH lkxN;PąpQmY@y$Dc"|$cxcHʁi +I\P/"U4DGLoZLYq1WлFK5<5^K H7UͮN@ Jf)m^l=5CsedZ|,KJpI +  4(T@1ѣs?R$[wj2`cVXcJ&l!/=& 6qPlD%7qE1zZ!xrcZl3RrVMwNDs +uֵ3"̔cQm\{5aC!H>g?d\L4Xi"f)Dp_6RfVJ*˩mT iI+)V:0ъ(1;猪hlu`KcBȅT;kw.1z5:M9.mGw;3\V2_w[PࣦpFI VNojgM)C>^,vB72r7{gŰ'rSU?za/wZObKh^ř"x`C!vjAq|Ĉv{el0!+uX֔Bx 1!`X@A\R2 uRR6څ&8T gGKh~X*xЛL+|SDd\Ĺ5%ḱd ޶jӽ^zl2UJUtYsC/ީ + X7?aDu +7*xx'pRHӆϠ0Ao{`6Y9^- @UNV"dӛz>@rn2~LJg o 7bdnԊWZg>bNit6]g>z W(e5{dۚWiDڰl}JU\3tK75Z21v_{WpXF <B) _r6x%߭jC1J +%24/!NA&y0h A 0F&ziTЬ!B@) l/( !gYrqe-O[B 0Z*RQr]+DuB`N^mQMxSg +W)L5ҭ@>UN4"0ҳ=f£ns⃏hD#+M4m0 Nfw^hJ?qFGKaBNoU:ER|$N(@-$Tlٮ8Pn-?x*A1b )(w{V-N^S S99~5Y\׺.[}db+JI@hOBA/]>y_V83Ul!XJܰ0l-ΊYN{ $ [1?|7{j}MMp>#vʬw`p~v B ;X%}VJuN?bNoQv1,h to=̩:{n+CA:R[ r\"UIDr)|1hJdq ڛj]B%FL!@@\`v EV8A#ze=1u?߇VӶ̶Uj]ŤFhUqu&n65 doTeբbJftPp +X9?Z*jݨu/9#1(aVd}5%si=ZU뿵`.VnINvL_I6R18G54ZI|~29S!ҡ7Vve'5ihe +mJ. :in]4 +4regt?L*2+q=b海spJRh|@n]a2Z v{N6$FH[8AFJ΄uVJ]c[;kNMBh>2n mg[Am%C;e2|+iܿ0FWZ갇~k&9؉f"Q2WmPb>1p "'wTm V-$g +URMbsi|V`y`)'.V"+.S2Ýf-r%APZf?C yx)x/;àIˤWԟ>iVK0{;n)+iѺN~/߆f璱p.˴UmC T)}⵮EP뜃"y`ڻuiګ<ٹGϓqkr^(!2Q֘*e+P֒3'ĔW{hSTO&:7=iK5TuIZ0Cٛ7AdnjO!%kw1YjU'[.:`J}Y4 AҎ t tiGJHPL\Hrl).@Ӡr.YH ZdrQ=l"DLrO!bIwWl3ߩO>orqu>6 11`bңi|ak͝QP,Jj3@$J ?P# +Gɝ&j:ԙ!KɞRr[]H.՚JCmLySyhˆ׊9!/rz/(gNrJp tv*Lj_V)lx Wg{,Ft8ҫ1QUoϝ\&3* ZKuF@Qu9ӌ~@f4\#T1{I.-fYiZ3^!~y'?-Wr>ݡ<%5I T=WZ9uHFs +Dsi~{Zb;4k ʐ:؇&릗Q'MpA@:G2/C:NJُ{ P'ňݪҠYAi4k,0椾ӐK t/@sVZ)GbYƈVeB'S+4i'[->bIUzԑf%%7_\_. j ւ5TH ڵ=İVg 欘Շ7t?pâXț +m>7$4DSsg&D+p!4>ȡy! @"[hop!O0:ך6+5B-p %۫O8b&) 47)1BB& RM,4pa"$Fh6ՑbLT{!)&Y. Owq8Mڥ\A+5&$VN5Qj0p2jQ6D5#(=Qy9C@@8- PST( 7ZLY@Ѕqf䌔ݚ4 x>.]jAo[6 (LBt58ERȡ 81V39j->Û >θ;Ui۩K8U;1 >-wJ 5:9jݠw99A)j i]ӛA*bVS`zSX*4tq܃|t `UiPL*hbDdCתXRaqXcuH))h,0" M`̽ +gZSkRl$wU,uL=;S0?#w+^E"dVa>Z٥IJVKݦ6nvNM0\=C3ޒRW|^\@1n~jjHj"p`8zQRDsHi).Fִ`8+,@GPUG\PSrݟ69S:ڗ=rU:x.&K`UAHKh[SO:8ƃ7ͷ$b0htt=SŽv]F-a6`]-n,Z^D1ˋ*RaS.C;^Ӛ +W ܭ +bKICF+n5ip A"G)UZn+ {LfXK{$b +f!jpPtpadzf(J2q5b=#_:Eg'7>%v„PWO_$`xOLjte0z2ҊKH)H-8CNIh#:3 +RAX[;mJ9b׽;+gdpS<}h!dN)T 51#^rFZKx:?QSj3i]ēW7iqoZrg`bRDOxV(qΨ}љkaR_ +$nh;l~qg&cRSRAAQNq5Qψb΢KrΥ51-T+cX`@ + bUcXk &U@]VxA5¨Q,V%ظyE{׍5JkP&@=#bhE wp + '* Μh*d!wd@9Ld{/^У} !G*FO:@rv~L_vh=^N0R1304}+;P¹=_`tukTU|n.vQڴ o! ўL5^ZR3#?W#cK5gm0~-C9HRtct ՜=#Y>XR 6="1gkY#TP#s%ӫ`pvg=-aX`1 'Opp~w06)nkjp me'vJʤCPZ˶n"hjw:oۇm|̷90o9ҍx9>J >@4+U2f/H 6--t !3}LBiY 9yt/d3?A_WN\ޔ3.%jިx;"h-^ 3D &8߀Uihi:ߘ١-AL g5Pv_q՟n㹘7arv]z]<{gs^ǓKD:˛]<广~Yv} +@Vv'[vN%wЙ9r[ï_P#8S"]/?:9H!N_V)H(& +.ZLzz )` +̙;W!rgJZC{n:Y`R<ŭ;NVDd$چы>|?W-Yc|̾.cNɡo i7ĵֹ&7//BK&᭙V.9MC݃px΋;-=܉:"䌻\P6@,d1(n1U2@}:}9>_i_L=Xvu6_vG;psh{P۳/w9LMݯwl_N.>ape{f,_BrFZGF*y1Yht5=N/I{kuN[WÏxCkݨ9KN?]}o7w߻ ݹq{7^ﺳ|7M4} &l 27E0y,^o~?M6gpE˭BcCGeit۽J,[({S`B87/ӟ;~nzw8wR0Ny0};y:_Ο/K)UK.|in^rV!8z8 .~_wClfW%yo;tM^/~i9$|Y:#0x<~=:xt:|vpG/~sjKx1_n?\]+=H`|4GW=oTWI9T9J*C+uy{rΙLN61ـm؀ clpkwp5>OJU { VA^FG-Pz+9E@Kodr-KӛD2pFzu\wā+BNiPaT~>GSpe<}Kl~ NqQ]ONIOl3᚛ U-Bf. WAt7)&$(%"ڕŅCw_{ ,T,f!ĉ'b'+E'T"Z{⇿RS#RKǸ4*|j0e/69򶲊ֶc꡻7>i-=S=X>[_<8X]ںQ|7&eJZk*No7=U-&1 b}1 ff-$dzUC]1+M_Yc#U2}Lj lr]L4SKZ~.V[u6^﬜# +!ՕLw2{"ٍ55Rk'6θP %FmBul{+O{DOҳtlOo$;HEt0 +]%3hS:`1xcM/̱!Ob_l̈́J R +gxƻz/꫑ƆZXL 56$i:~kOpڰ-@\ tnHԛ?O-Ofʹ7w6-{r +6u_mD+Fadw 1z +SR%ef\=}_~;}`>x\Ċ/ڸQZfƢ1' 55=`Q@HeNR&"$H/}X@Gz0bUS]:j2wL-.@B ʙϰF(ԗdz4(gH}\r.䄇(-M!1֊p7{psfmdy$(rwC5xe+p mZiOLˏ|ﹷRIOAkMtf/ӑ.y 3yxWż8*-'^xrrC5Qez;@&ƷUTL}."U`=s5A'~&e{@W:73br2u6;LEG`e4rwA,|*7KcsSwO]׷BEVmYnX!&@n#">9c4Dq>Q)DžQ` D'A8-!%"eQ7;A)B@9J`u2:BlށFk"k4J:ugz2R +Ӈ>2@VC-P=BNpg"!6[`19G^y +`Q5 s@&`n`R +.ʏIQRM)-"xrlxTɣr +VHԜKB} JtvPEE@)7.}(> p=p!*D"JɯԀC<UpeZ/ ގ#*8rSO5Ǽ ׶]SeV9rw4hia|L灧[}xDw7 jqT,b(͑J&'D?\@H%u'|R˔VQ3ɓbg{峠F5rUPKgBE`]D +URkة;:ܰf߈  'c4eE{(&p&+ S1%S>*<;B¸p8ʄ4l#zb|yBg8,sS#SV木 + W9r2}bn瞩;kE+sn? ]t0 .WP.U$$ 勥#^|>{.ͮ-O",`Ņ`gsv/c1(kz 2r1ے*„t@EXܔ0f J-&@42ͭqP}t+RYra.4Ĥ`>/%R B,8! "jx"@6hsހq2?syi-]-\mí9GyPR|.G=^ zyĊlJHv ƄN ]۫<=C6EFˋv#"PZfNṄk`b6/F75Nj9)R]=i4gl8"bxD$,C#`f `ۃtb값qQPΨ-zɇM՗y.\vꈋe55EZEXu5*.\uVk"jsF![@pqNΪzihw|md{@7C7q~:Iu:R$T #TMhEre4{HAS=BJ:` + Τ26|*c|7S3@dž3򒐙a<*Lsaccvt̎|[t&&]HhĆ^0&&xr|\ĥ~eTzI݅h/7q2s?..\ȀHwGh^oӘ"H)TNƇzz&c"亻)T"|HaZOr"L)zcf~Pܴ-1փv^6 'Y |\E}ekd(aE;.j*0)9L4872k&BD]:-C%Ƹ)546Lv̇!2brl^*>efXV/Oo'!>2\jh0oCyTD R &Ϟɉ…ҡhĆlh-%rgzn.R\Rc[wݵ߁ 5pɽ-#cR/b\v̆!?J178P 6CpbT dP"L +dؚ\[?xԥ~⷟qcI&O!V":DNern|qvhȹxw|~sͩpfna"Iu3%8#kkr>.㍙Cxƃ}ᵟ_{{Υǫ$f4#Ox`ZגU#ڥBoɛ?CO>_O_<͑r.Hqfx$ȴ^9}~tGn<[G~=+Wyd N?vϪH+TZ\9ӛ9~ȹgWo>Gb<@ѩx~d-6 qش ze#8g7vtyp|w|ǝ 7Ӌ;9cG싿|g|_oꉧ_\=lm?᪪&l[>y峗o>w_{W`}'}ͷ}/_|oW~G~[\/YLl$ӢK* Μo?r}>{xcϼ}_߾ǟ?}^=x%VR^Ivxښ^>po?O?ܫ !?`(j|_˳=fC^:XY L\?v>7[Pb?|?>Ͼ8rxa ""-sW~G??Żλ_|/?o}٭e>txlO B17F9Wuço pG__Ͽ}>u E񙥥Gpyw~xՍD&~L@"AYV izX:x}=3oƋuSK^ˆT/4bɛogoO>W?_YM6eԤ Gcw=tW_{㣏@6|;'嗟}?'~kor_5:8$LOtba=S)7~7~ӷo?諿}O?ꫯ>_zG/u(Q +\&Dm|barzaǞ?_zWͿGW|ן G~EV'esN/Fsl8{慗{>?]p>/_}ç>sWnEsMF9|Յz^±֭}_? ?@f~/oy~_G;)8l]>e0p.5:sGOu#/拯ho_}?~./VcTu]SjTirTveibfb}}3?xճ/3jH) ~Qq8 `"*7jv17?v|֭s+:rR{fAfX5$.}O#IϯlNRBf{}ܙc~>Nwgg#!s(={bk:_mMv 3'<۟|__y>Cwmg{:>#*NS!7"ځ-N%^ɧbVy_~퍟s{?zKZnƳz`.cBpŏ+* '>?x7_飏>/y滏]xZ/A(*BV'wux;j# F8311=x/^|_~/>SƒnMe(9d\4)zAبZ_߹vO>kW~?K{ާyxS(ֈ9`@ D͵kW+wc><_Q)V0 RGa +9IE3\nD%hqɠM;f@P@Dp1L&O1ͫ $#Lj;"9 ndjw`Dq)G(^Qd@`!ShM7]]$ <Xܘ/8E$#'n\&$hPºJT\Zx,:lV o &'caE *T&áNXEf4YIaIjCV3FL۝4Mr.WH$JTkwwxQSv-.!tѡطߺalN8LHX +d2vkמ>KjJR(h {yŀ̲b-_hNs銒)ÍRKQ>1?yr 2 "Ǣd2bJRRr^ҌCQIZλ,w3 TbE 1T&A{|tZ {@r"6eC->:ʯE˫ !gr]@AG I  076  lnBZ1rsĤN 9`Xrn h~<`ЈosFn32$B#n0 tW@Uw3d(&)d`2}.D4b )71!=d3fy`1]Lsq#Ub⢝{3j(n@ ,~ ",.bslaf'ҏi{1 /ɘ#|lqe.$H8[Sci^5V{C(! +IGsߠ;;^B !"*p{F}lCٞ׆M[́}{ǐQ+E^(4ba!&;6"=:rZ?e!2V8lr;C{t eR@Q3|]i̤x8JI7P2&Ȩ19rW!a%!Nv"+~.LcBP +#-6҆ [!mȌ_<R d(BkMS"evXPfZ3\hfD `'PX#PrfiuSv7N+d%1QQh f 0#7Иq`Z`ldž˃EsV'ef'eqހfCZQS礤'LىLnǂ@0IRtg +cS)$A.D(|}TF܀u +1V)&kjz"^:xDZ.LM67"Q>joo@RVjRK!XK7=x=DpFgLs!?FǏ)AZ  rsWl-)Hi.9Cmrbi!BCۯٝE{ HY%d'Q +KsVeTs3\^a@3A ΆZa^o@Jת@^fqnDBPjrJOioXbPS)T)&V|T ;Be-ZLlqeVaL +$ذ%MYJHo4[!3]5Ӄ,bR sRr۝¾φ0 ;!"ƫhvE+1SpI\َZC.C%̆*AfKapSe>Kө1ޕDAGǙxGq.w?X8o8/x +`!E:]D|¨uA<&^Z +.LwUEK˅Nkҡ{1)Ay9g(|{Œ"r歐_M'S'{.2nGC\h + iDL2u.$<ojN'@,K a湛OJ@#Ab3@b}1955pv֎2Nwg%xg7=\[?P䢌2 +P<uMZ/ݬx"W 耴AhAR:%4>\&"1LPj5FuEN|O@rʈ18P1RbBI"b ll.,/ÅRd\Q`(g2 \rDOރy8cl J؜l4*fs(=Oj)RZԋSG/hcRƨo:*wT'u3$鎲 0f"ʥA:p^J:jvPᣃ2 [a֊zq9،ͭXcSIO{0Jerk&r0ݼ>V'3Ơc^"˵ڢD<{r0lPssV$wK pqu(Z^["*'4P&`A-9V#}%F\grR/Q7"s1wkߙ9xx^LQ}ȂS7MN·QG +@ԑbIVJoַF,,UZX V lоAAa3f~4dvPaփAnpiaP QĦ)[a"BFddvUD4c̗Ԟ@D@fhg/r`r(?]X?\;[?O6L4 1H^2tnZia #@"B&QgR6XHYL)@ +IFJ:ũ#ÉrLs8vnvdߎGM^P B0:+,[!LH%6I6jŝ^cA J jf4{&>*|/rq S͝cLaH] f=\`}&dxyvd=XhĎ!0`~aKtsjyu#R H(f\i N+&f`{{H( +ՉznJJϢj3  ^*evB $mfLN Vb(ADyKw9tvE&6k@FHW5bBXo*{%@GD"eij} +9qp jaKӑVZgC ˀA'9<+8=fg{ȑ'u@\`RH%h7HSs6BGnLw!XAM΄&ŝTxmi )ں}~rT U{'ۋDa>SG%X3l#^ I|t0#&T|u,VR1#8X¹rS +6/'+̘3r uQ}+@ˑ֡d!2ggùeBnbvco hrP/-pMOۼCK陥O~|5=y" qԣ;u@؅ЋD;[na}c>19' p>6M(i̊X7`.Gk.=PYSqT&T +y &z8 1/"QbrZpn8lro?;xOA( ~s`n*XMwD @¬p鴜nhI7ryYC!4¤A NK؁  <`?&#ZWQR!nmD,ϭO`.&{Fm[jf.\`)"V+hr6 F@锞|ug~~; JFGKkݭ[\. [$@O\ك1&ŻP$EuaӒ(=ٽ(=TqATΖ:'V`\,4趀b `(LǔUJ]Pt/w6rV;t +UL0 +ɁsR|.TXM[3_Tk-fo2P@˫aFH% +Q=B&:. ݐT6X|2,'N|$1\)w9#vf*vp( ;b ܹ6y=J$!$bp_8 +q1\NnZ?ƅ1IEJ03;;p Voq!, LqJLxգ Y1VИwCuR@tԃ)w; Sfv\]9zxZX2qGy&D + R|/noG @AR\R89/B``Hh󍕝12=,pja\fCFČyMV"1Vz@ޛ8zn0qDmSr ÏQ:hE-3 Ttњu 9% +9lldd!2DI-Zq5m;}GBFK۱ʶi``3&4{]AǹtKګw3ưe `vͫTFM!gk&fy`(<&ùi9r2o$:ۥc מ]> .LAt/<55j96a cN +kE "ZO;kۧk,q +J4 +K^Um*Xv 擃+DTb|Rvs"   zΌyd[px{ۿpj9vfTsyjʑk|L %c鵳@zQ gWN}zaݏFq/cvN7j$pvZLJzarc#Dp:T\ft` +eT(9;f9lqǘEd{=ÖaSP`ɘE@KS/DeEDO#VQnr#89t Y1 ++'>LvN]}j g@ Z*F(.`"VX*NQ| J;JѠQr5 Te cnBp4P80-EkQX9|z6XNwH b >\txi?a +#xQR6QqTܳuK9 pȧ&8umr2)zuҵ%%H仜9Qlf5%8 ` ս.U +b ƪÙq~s[1:ҶTfйLhq!aC e6/k$LUT `&qU0@0#gm."]^d]C/ |O +q,OM,ց$1_9x00.1byQL;5Àk;upnH,I|8ն;PQ:a t!%Rnd}`zr~v:l.Ϊ)p|tr/ Rltfvln81Xdd 22ШvVTg/\}Ǥd sr(iؼp>sR#+,0^< Y>ܦu"8a佚m\Ol1zQJuzsxy=WwN#Xk8PaKclV@9g@: z!?8_!kX^Wq6\D9)0`A\e>_$H `3fsnFTlr LkUJh;3@R" ʊj|i4rBm"n~ JlA@ht4Mn|bz.xlvo- + +MRQRsBY%h`isc>v8?$`YV(Tˌcxd Xu9?-T4\) +0LБt7Xizzn'^MlηwG H-L ettH??˭Wm2r9|/ps,*gVbC9A"@IVm(۲[ng̼ykz_ށ += 9{?sQ`GƱ ?Vs~#ީ U S-4SNWQ1ak4DON Fnnrva_@AoZq8c&X( +GƀޙTHٵ͛mh~eֽ/Ovtởw:"UtOYÛ)^.w @j2%pØ )r$ bRZ̯zyƩvl">ӜۧVz(èVvhobviZ#S!*bkCO5 Aؔ&9v/% ʸfcV̂jߜ?py /鯑AtMDQ5lBTt3z}xRP`ם *Κ-<R@2Jr'aRǸL@+\xT_aAeWH BL +rۚc2FC/$z?̄!X\; R> +`vp̩_޹B(nn/ͬ^73s^f|~flԋ`)9FrAD]yNIu D4jN x"3\I6 ZfJI4Ng[kX-D,p+:(hPDT//QHRZIƩq8@T&[RbZ/Yދ{A)x]پ\czR\$@ aHN{{1˚ )lŪ3g+V{Tzg}?(mSx?1 qQ-TOaa)$w6ض3Sٳs{7n+dׇ3͍R 8StPOohV2y٬}p0T FD +{ވWÁN,!,쉠7ʅq5Lgj޼J:]ʪd{b\ܒ[FÌ=`=?ȇ⸗8vzо +DBCA(9>YE,6n&&RBo/"PD3 qQ%4eK7_uiuvt0t/eSʖ:[.0|p"'z~AXnjzAv.Uۗ=?{`'=ɪQ?w|;1Fa6gfzwa qT +aTȃ+KZqKj ŒRk7G4Z`2>Pq#@@AD/"1f5*M,>]4n~}U뮏yR(*Q2ްH9 h BP+^DeyudnJoK7 zKO՗ L^FHB~4,gFO$(kwX,/fž1 +Ni;9`Pg*KW~P_iqnI_zy'RmWv=! 0SCx zglnri nߺhmz-ʸ/Dq5 bPJ9_b0Ht> )aT #BT'_ch(AB+g`,RANV;fn>^]ukSw݋w綎"adgU#Bh5OTb0*p/S]~>x +gy=72d:޻ǝG05.=4DZn<{|uENQ7͝ɑ!@ iBFHM+ֵzco1~aҒ#? +!=02(h?4F4X)C&p .8"mI{On~r`E;Ńꜹfj+WsGj_`S7DgK+Ag\ml<3;g|{e,&gRzEp +Fbc,PaDG$_X [A43L}2@{A2aUx\랹֣~G[A;,R%9?qWoR>=!bӼӱJaGO/|4Âsp{Zi#YO7voL#0ЄBgW tj M67=PAF-3w{c#ى`WLsWY^oz#‰ෞ9~?C_x/nfcGF+ߴaU +0ab5ӭ\&WET;|xJ%;}Wp~=sOpZz;|*%Ft#t#RncK_ ccMSWdc~juЙܴ[Rv@f!fI eb=*r % +nM"bR`'a֏!C#L~BmzsVL?P 3fq]ήǺqԉ0+.)d+lL7I(ɴIψVցL2IP=!,򭵫߹~͛¼QF5B r"(e˒]3p)lV X\.'o|6#Q@n0RQ.ÕP==D$'>?n/GE!m#5o' g9=DuTvls0_=Wn;Y9VϷ6^8%#<6=iDI9JH1/EpS Uɩ;]pa~~SJv.9u*o$nۼSy{\,%,|FsNuj/ 2ƣJMi شڵ/-[Z8wK v}tYz+Wڛ'g.\]ڼ2F kFĠ3L&['6iƕ +c69Ur,rkѭ,6  ^ Cf= `EPMHpfIA@ vcBwQLǾq#Lؤݩ(n"7"7{Tiou7Wfk9i'%+ц)?B%6+[ K! @gpJ+UN +8?~7hQRcǎMHxKdX(k[Iv<\-]5㵼hƫdM؁[7yJCȨ$5O@x00(m#ÚE0kmw7.y_?(v_}ss&הxUOLK^Iy!QR|kV^[^\4Rj.^۞Z{gξ+NN]4rs>HB2¹&! 7<ƚ3Vs$(.l^~3[RMҨkj"\^L̰N$# S1oD DoZ~F-Př |bu&&DLBX~cbF9_elcexej}: kZj7K!O4sdAJVD8AIyRAOC)"%!&&/$LwKG=ya=J9I59j H.t[NN NqAI";R-gX%-w#aƵ04 wz$t5 3@t;;k7ui=e+Ni:SNj=@fMz[Yȵy+~*&DWeȴ}joMU;/[SbsvmPOۯ|xwv0w+_,^ʻNy駽3O` )ٓVla戔3zꇪSC1Z~>:;d=V͜t|RB <,WPQy\kwVc!~xa2 +R| |jfo̭JbM[0Rz7ҝB/a^EO0j0 ʵO Ĺ`d7jaDXhmJ2\mwIHΡB!O]c`׀eSSV~+F )a* Fب N E%SjvN|UI_TS4.&B bz.a*gbIɭAKLeιͳ9R.P=nxNO`t2&Y-Hno +),HdGHH֝jt-B=I!-Fv3/ŀ*;I0PX*QJۜL9ߘ]/rOtRTSSkmSޞ:s ̤l(. X0U[?{+\ 89=f#0B;\UӋ +A.*_u}L)ٵam+>]|lo~yoTj}ig^O C Zm=8ړȷ6  K_K7̥[oLJ§c>'R'JKݵv?P&K+oq}B޽V:%Q&85F{^J}lώƎ Xbfeo7(|nJN&(L|BLtznv箒ä*k7|0н⨏Exs$5@fXDJ4B\%D#QTQ8 N&P }OHFPa&; Gh#R b(F}^hxd, +!fd 3f>{)! { K<>mNPRDRbV0kZif:U05<ye 9 o}7?%PQbh J23#㡑QؘZ',z?x1ɷyم1>njN 0Q4'@ GyAIF!' Y{ MPsiQ4;Cp?""IР'CBs(,Z-5ޑjtfaP*mqFJu3%1HΡLك^~t`ń@xa! EىhTY9zA,0V%RilF7(BC:_V]Nx=p`"(0 +K .Ѽ $'qAy<P2`(ԳώxQ9?}bbb$D3nIKgo $m"B4S v!X(+GǠgQXAiϏx{t4"%p\XjHc:6|ldhNk[( +瓤TD2s_7^D>qHA+Frdp}yj¾ ~i Q1d' Ai# q``2$(h')rk$o!,fd +@fC $QAX8BRnv&)Ib妝PXҶ aZUJ,m)lZ\[o!pZ<>v؈/@P|L\]0yOV +&c a==P"`/4n7vwozot:Mgrt6e}ђ~up? IL$TDiѷXn%x𫟾w\~᥿/?O?s{ RI]Of&*ss=͹'+/~ޕ?w'?ǟ٘DCYVE~"a&qe5uy=?c\Zr]{>x|l"E8 \iwVoxo|{7oݟޯ?:^?k;slLxɶM,Ԋa+EG\R_OƗw>{mW\zG{[_7 @,!@"W,u9Ky|zǯ,+߾ڿ|=;oݻ E( +z3w)__5?~.~ʧOţ/>7on|޿ݏ1(1C0\QkJ|۶_Tյ~r?_~/?}pn:J]WX\&wj(xw?o;_|>7w~7g*^ U$x4H tX߽/y??,{xy^ A3k\^G}w?ys^??|vOo_Oo[G zֈFhh>Z3o5d7^Qnv׏r_w~Ƿw8F6iNY-˟Oz߽ÍRd8pQ3D\bT3wR䅿A璘˟{BRQhfFP28eZ&sq.vmyzWG/ݣ~ϼzy|̙`cҜĈ^4銁/^Z{?}_~ʿ|Xk^(<>^,a,SS8>SVd)9Ͻ|{JW?{O7}KIs6AbJi)d1:צo_}tqV۽oW>~0ӗKeaHBɑa i $X_7\l/}uBk7n-~kBϼj,! +o ce5ŭd2Z6ٵFl~+_z{G~^ޱhgjR(**i͙BrpeBM ݨ:ԃ3K/|xW.-"ADQi(hxqRj^{|7.٭L|F|,)םdGL!)jR?_՟L}[{k[ە3ng,CFY *L"Ո1 Gl*:j9&\~m2vXvuVs;WI݌6ƢG2)W#0 , +qXWlJ s?{|n<\zE3! (ZV:QLd; (Gseǯǟ>׿}w+\_UYmKoll%,r[˗ 3EBbeVd)q`+QDsPHƐRn5'fhrMIR4ŋq `1)`?DHSBc=3WvCT6^S▞pt;39kM)%Osr1шe2`&53IRC^W2eυ1+AXcA ?'"L]ܦbTx6xRʬ^̊xW^P0#Bda\#xG4I.Rfh3x%g֢\ƩDX,o8TQF!\la`C V'=x78/orYt=333c?$`\Պ>yj5Y(%OPOVx3Q:9~F]IMO%e_z5Q2:H8㔦vh1 RV1(q/YeTʦ +}E`&;Ff]M`s<'<~b<;P@+zIcA7(X8(UQD܏ֈY,V6Uf"|S foTw2sދ~D"SIƚʷϟvbI/E66hk!TD8 -B:Q:Ř]11GMެ1j  .Aj@LBKv5V- +V ݲg_FQmnk> Aޅ٭>H&b!.Hav=;-nz3ʕ`F^+G=ˆi%\a^+:8MT7Qʵr$ȵR.螰F+%/Sƹ~ AX1ZRJ`M1!O+1!̉i+ƤĦNCc7=a\b*̺e3Ճ!6+wVέH#%! qta%W>50NmŪG`ihzot`Dq7s8::i[v"`i:6 SC7 je;OrM?iDdq39lSѳ=/ G8p\OO]J&uJq,j͂@D׏>X)-WIs0_[{pr^Ffo3%@hFuIYMR"`6iў=XB̋YZnŝ<Pyp> d\NpR] a*r ʉ8'D;`f6@lr֘ޗS>X Q ڞ&r(5i(4"efV&"Of[A18X\,b|~ȃE8VhC]\y`ՏY;aRcf=*aLrYh8kwhjHlZ;p0>^!D̉c#oP(iJtR3ސ ~*q'@BId팔$t?̻VI.>XM+?"6lN"RC\58g(u|wrqVIJfV.8̕،U߳&XSvJfa+ӤmeWZX~;8k(yxBgNH,5uήu)!3}T]mW7lv"紃Meڞ}9H93dpMs;SKq>*‘]GfUpꦑꏄ(S+JavfX"9xZ&!E@huwuJ]~qIٝ ٹbj +@xj龎OPF!uǍDTܜӫgNƠnQ`'n<rKZjқ?,w|ix\~zeVrrbc# 1T&RS)3=$z0~sVWIH#?er3QVqeqރ^AJ{7gTiR֣d`&I6~K?ʧݽGlrPd^~J;CdsݗSi̞KTQ<__a&xm(?b%.*7~sjLlUvK;-n!|Q ;Cj(2KdSPiJzzvRڙT{ǹ 7o'(_>aTWG5xӫkJ5waͷtH +m*D!"Ƣdײ(m(Jɶ 3tg泤Y$U0}Ē7w4eN:s雹[a֬Dgfi`#;g<ݽNu7VbT +3Gvm;=]z%:SnO-LR" nL.JSJ-z]kP1)9#Xpzk@LJ9ݺ?ӹh,mm%[kZ#S6+gxhN .ۓ]!e5j"ԠU,r3ٹCdZPA\Z-Y=%䊐0+gG"yAT2rnY,֠4+`sz|p 3.WNOC^ DZHJnS J5+B ~Y{b0hDGC\(.%2#ggD[]Oֶg6d9P9[?Dpn+^-o'fy7@[]$1l69Gl\ќsqw󮚝Jobs"^<|ꞓO”|UؔS+$1mfPtBzIʯzGkWl>05nVU)2%5p7&) DFyYhl_m-_e-$XYsB|r&njO/?6 Fլn[bpGptF +qT(L *DP\] 9`v23Nj[w_Vsrg\Z#c]zLy. ɜQNO N&𓒳aUSydN;gaF`43)FE(aBcvHBS#H1&&h`(E`W*q`tJ׀9`n_fS+\Hnu|zlQ޲{J~Yko| He.fVk/r[wo2͍ iF?ӿipS"沴Zs {qC5#4@ÚL +=\-[7 +B%++Vvljڵ6DM`̦G}7"0ze_/maRaVweNy)br?pN`n *5OOЀCjJŊ[5RAm_XFMŘm= +Qi pab.,nc](yKVuM/,rX?{oI W ;ݝtkG{eUeUwTZhz3 ]Hs! ]cl\0D 'A9bnnOr!4[+\$o6MO׽A<?3&g_Ogݗw88C;߸Aw~;{L.Q12a69ݦ &l8ѓ.hf~KӚ7Wϧ_e0HdOO?_\~>; _|Ѻt}Ꮫ۟5ot+^Lъ?l0F4<o/_\߻ /w b ^pg106Y7B?}ӃW`i/fѧ5Mo[_>|՜"/oG@lwB]M48?ÏDky*y+Ͼgk9f>~5gV9F5 +սyqꃙƴ?ov*\v{$egI>?Tgtx-Gz~K٭u6Q EgD݌wj}/<`6J_*L0Iw-%T4Vo'-o~z t:Sۂ5T̕`R[`c7WrBnvBvhӿp[k5S@(LWg]F \Fk FmqF<#YdÓwช[eFTp&QBںދ?~P } Q2EgN rNz)5kU'-\hjs} HD^oր! hcA:ejgh4~,92?&=g֧_V/A8y?|/B颴N֗_JPf~xMngQo_.y>k^dOH>Q͡j 0&&6EǫMN4P>˗oqBPCjyg&trwN:{{_0RX>x׿_`jav3ۇ7ϟu7p6\38tE Ι|TMk6{Rd茝֡>ᬡOtq(g;?;gYo8t+ukvPV~dwO$F[r;I{c YA V炥Qe/ޝ~M%).73^e|v>ڜb\|D1FRu:@ _ fw&|]}?ݛBQ|:&P4:? ۳K fLQ:~.@0J[ϭ5vrǼֶ7=-$u ޼l֚6V?l\Ft gj \-ZfO&_SVJ%flV"#a~/.ENOpcBǜG~FŠ5wfW56xe&vlLfӃ78Øm޳n)xl0(Rj_pfgܻ2o!eHג}o%gbsśR[nE9UqSof$v꜐FB@U>(0>dm !Rv7Wu6.xX4`x=KƟp5ƫf;ҼI31>XWFov6SCBL%a΁^E3KZh9sqrh Jh\KECJԹO>'O))]?& +:F3CSB,;33䠚QkۯͭteNYlk frJ(0RiԿ&^x4EcpJL;3d609;H/@洒rHFZv{7ӽ a lOuojgpto .T F4m-g9\g? +m޼9}mg~jm|NW1j epp?nB5f܁Nx5?O5]0_j77uZ'Phl8$!Ưg=0/(d>w`ӳOML 9XJ/;yrE~~G{WJNETxV ޖsAZJr'ڮ3#G77{=H\7?Sut+\UhV ong<|G2 ApGo] (Hnc͟a\ + ]>R=9qћ+RC}C K58o#q 5l-JhXGtMw8?SXRjj +\$wpef/{ NCT;$vܗ*4#Kz 7j~ښoff(yv?{9?`ܳ͡)z7>^Pø{䴏DggGZr2>;AxasNcy88:|>6%g!IZ{_ג_߁ͥtOKkdgf\]\9yWۚ7΋k^eE3ޚpR(HV8cJCk[ّa|:JPΒp{Oݷݽ7FhK{/ PF_t B"]TMщZk` mM|\+XE4tes"=LO` Jumd-p&{b 5<=^%"wd;wM.֛)+vp2='gœܖb^j|<F{t/Ι=6jlo._KR*@Q&3_Ig&Q-j_Q"Epz{?aD<}(%-U8v[ǴҮF9lnhqZyNo'FMPңa8z1~!Fux<lH+1JjҌ,Dsqq fw1Mz׺]Cu˙uFuT|m8YIƠh[ھ~o:_ X X3cRk>ug_s\De/7oAm"SW5'v*YKfZ֪7-T.qj `PzWttT,o>m_3J"Qh%^:o[|t"*Tj +&7;ZڭƓFbˈU\&%'׽o LޗW[t5jLڻZ~]xqpN׌\xhA8͵ŽQbwO@W xt#ZAzQFyK0-P5x6᤬0ܪ2F#^i<\k٦E5ʑ~?U6|-{bA|̃?qvhlJ0F1U%V.Rd@<5C3@l$Ca{ qRg.x*` +&B +iW{R$#/zu\ݍ4]`=x}6{ae{ +aBD`5@UrqVħ{NF8_tQR8QRTSCBպJ a4kb3B1::?9㤨&Dw/5lnA(Z|j}VJ Zh5ho`7@0tAD)P[œц5fUTk-4kq-J/Boui!%d;69{\HǙArޡ4g2{-]ٞr0ӟ϶Jf6MKQE)mv0\2;PeZP3lFsCǃk IO8=|#W3D<qYj.nt)]CA`Meee x kt/[*Z@ڊ/έ&<k:N +a~@Jk}1'1o/eй @ղO4^G +6g&kshDsJjFQ/C2 Ɍ#!yΠ92fI_0~܌V`NJnJu6ۂ=k_ǣk=y3,X}Ȼ3\nUi ` Ppsڧ%-'%TuwƊi%6PFUXF(\u uoTkVjjˈIОdk6ĂXCM8|%vx)R]Z7!dřrh- 珹"rLHF)ƴ2{n&K^qE+7`Br3iI/! +'>!zrj +U)y3)T€bM^oݰwfPB +&Srk:Fr w'U\'X S<+G;hf!%8~8#163њwOPkЄ & 08`$Q.Q?eDOEgڠ|=>r;7v~udw.'˄lG(% `R(`{ 胾x!X6FX5s 2֜Y^nȦ5X|``p7'@cT( j_ro" jrN:h\{0R m`~2n6Fb@2S\rN99x6-pMu$jur.{PbYCHAڀ.W]'1%ff;M{6;(ߢ~P&`4C= Fb] * .8 RCiWCV*hWN稻zL?mib4י^ē5İbvA(5݁b65:91uhW4T26sF 5g p!.pPn밠ZA+MRs\J Z|$Kh=ss-4`ܯ (1 `G_b)E +4hj ~/> }$U0xq5 *X AVH+c> GU+#Ń$W eDČ1ƂtBS(7$ XHa4OrcjC#ܨ\k d + u$h"8{~We}x +q#YegF5Z}@YlFh=}|1K)xGfAF +N1$Dy+#>Z Nd{qykagfr`b`+܌7֖H%'LFhsE3hZNplfg9;WT=3ݶtn-NZ(>0>:<39BiqK5'h mAk~8zEG 1S\luN۠dO`\hBtu6=Puh QfSp%I z`|zcVKeohŅlOGנ7Vg_[/xc$CE8BČW`!O0}xNJ).97A)IN\j[4Jv>Cdj_~/dxev#<>,ހz!t{uRh3-G??B0`@'ȐbFV]6hJzu:hy‡% -@MZas3Xph \kCZH.-k`Dxcmޚ[ ZG`JLipw!TPA2ڜڄ?17w>rB-ZI8k=>P SpnGO>ڜYF)-#=lbTqm6Ei L<`Ar}X'%H`1@h]ZM`N^ysj$sp@*x1#ih- ,`laލZeܮfU۪ L@FewiNM#+ڜI؎P.╙m@`|PvVǚ? Z{A{UFjqf_vj"kHcuGs! BDuگVmp _hzQ*Xqo D6J9! ڣdeJ"x0jb߼S _a'rZ 8;pŽJalP.FH5j$sO_Zt L;Ǻ7**y:K#O'[3RM#ZwJ1"xjBL0^Y0z'v\ЊK7K߰ Ğ2(>y=R!1T + +Vc%ާhQENTlIh(i4J5@ ,JtVqWWW 2B mIVE,40ڂ$hԞw>QA!߻RvM[ EOL0|5BN[;4Ȟtt} cM )4+Zi. 'tcΘ1ڰEe¬:[gogO蠄Ej) uhsN\*j"Ż=3>)9( +`*a4QYstWϽ \4==lSxCws_x/%E`jLEq:hꯀ@eU'l0AHBm`Fk RlrWW$!i1̮\V'_O}\N$ g/Ie(;3w +ꮋ"~k'(*@S^i oFH)b0*θH63W  +=ZlW?茵p)sX\`Ph4g7z@(uN`mhU9mr*51C+=C524D!`KBr 5w*<ǚ.Ի[>9'm+~uN P H#a5aJ;띴ƗWܠC@TnMuNSs>(SGHmu&ܟۻ{^)5^NE ++-o ycb}9aOZL;w"-  endstream endobj 112 0 obj <>stream +eIۺ4 +"BRsxO gQ!X](nKp%}فIsb?:D L"d&otxsEG:Ũ +%%N'6ОQ=Jmy+Q: +AYP0rF[rjiɆ').2E uԜZFqҽ-W5KH |t֙ 0&z}1:0R Fj;R^HRqkS8y\Rf=&9w +"B lX5՗`,)Y v06z5"d3 ƯF~[(2bkk%cNQYYqZvb~.Fj->ގ%o\6_wַz:f>Ӿ2L: 0Qv $'IzП}a Ơ9n#L`$k[8ċ}5=ۗJq"pbgc=nc-ZycX*bL-ٚ8nWppRd~;nvRZPł # :pF1),>>-[ӫ3LlDPV 3F_*D,ix39Z[6 %wYk_Ϭ! O8gIiWQ>1c#O*NMd@ wo59"59{lNYgFBV@@rd7;Ww]hJˑց͔pJ;*ez駛52DctpYIнWfzX_=#c8:&r| 6W_&\vx` Q/X2Y-X I:uٮU2@j֩ Rܝr)P-IIg7'sD5@?R̍hAU‘W; -˨Uk3φazտ*?ޢTǷ= H0;Sn eȚ{óo{}#8>mJjn}Vz?Hʸ PO"n_g )\q;{ $Pџ͍.k\C7VO}4(O5`e*_#-Qbw&_۾ +θu /w +%3/k6aXrOmgkDxwn]!%/Svt{R(ۼ1 +{A6tVZ[R ,V,!d?gW|&-$=wldqVuI)ׄr]AoP֫`2A~ r]i4\*ċ %؏K$c̤jQ#3V _KNJ*qeCPvvԌq[|X `P,?_ZN;-w):$ӗowվ_Ș}_@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@x侍@ۘo!~<5fGOT'z~i/.Og総8RLjGl9;|[>cas1;\UaSy>B׽F3S@MDBGG1x$GF4`Y "`#XY_6??GG죝ңnR"?-aFɄ ?j2G,P΅!S$ hCG(B/G8#Jߝ.n=yrws(Џ(E\ 9#=h&6OgEomKəR Ȼfw}glZ9s"zk XEx29=F3լθV2W1%7UoiN뜳p>Ah.<<$-NQ<;:UH%#䘳[5fͶ:Ok9;&." .tݻg^3JYUL#xBhqfkw 6l*$p9Ek_)lX&i\`K՛!Pl"E?# kw/1{jSبxq oL`NhmDk2n)&!q6†܀eâC5ќ9%5a*>[Uj5T3vAq~!<)eLm9Dܥuh} 8rC%U絑hdg_<6) #ۗYN' 8!^ b'AY CJy5QЪ*]=:5ms^<J!MJNd\c9ا":]svK)!j ZFcB룬W4g<  Yʈ  ..eQõ{ޚE *x0;{8 *}pҬSng%ckMɻN: JuA8WKeDV;0۔ä8ܥvhԇ quҮ r\U!@h(_',H$y:٩ PCu8v& ʛsRBW6Άڅ%#.ysjDBLטK$F1ҽ]YB(Uޡ:l}RAaLĩ#V(f*;OKmvLhІE* J uaVЌ6k'u;՗.!5҂uTUy3әN78>%>!w$Hx iKCRz!m }adk'_钵POsƋdi;5ʁ‡k6{Iycxk@ٜMygF&tzH;?џGvrcs:5ƭQNQ+h fO D)Z,6ΥSr `=D1XyKy `dfrW!QQXr p>L [ +n5ps1#) uF)qZthJ5­`TV5Ƈr9#@:uq֮z >@PPJ Τ0]%k;CJP;UlP.)Nh{۪庸U&!X)E+.W% \cPO HmRUʧ:%Zɾh)!y> ( k U=9t܎)dۨ +#ɚp7nw7`Rc_iн7=J@@zVS%<\Z3q+!U%k]zEuYgE8yN^Dn~41X(Fg EDc0!L`b +16&qbNtW +4\;zRK0M٨lk~%4FKuX NGq]^.Ph;uUԂ˜uW[(h*j4p~ K :a!QPq0i1RSAyR\@$8'eh ;B@7]xNm^q4;UQ6+eLBƫu@&f8S߹ 7 d=MsRz Ym{ i_uGw_бE[.#Y3߻._Gj&c)\KXѝP`Dkxs&KH=؛?o1IIh/S[PޮRK/B +(p<1҃ +iТL#7i]sp$)5Dў+5=b!ORnUiWfr"A +O`@A=2`0ڃz&ؙ:mN$LU +4 <`.R2 +*Bjy^Kt + ]0eUb7B> ¥f0~Fq/Z@aBAvIpx۝Tŭ?߮ohP ¢j;n]hfT_`Ҕ!x DUbۥvC (bCP@`@Vq]^˫dqn/ZΔYEM f>#YJt4s_Rn]$ur4dSC>H!LlaY8~A-fvQp\h;pzӄhYe +n:X6'='z@0V ʼng pIR2+r ֎k,x=b9'? AQa!ړZj4Tz"2<i dR Z4 ``(0c e*"4, DBq0! N!WF8l.ց@1*DΡTcy9'jptNZLaeԬFeD`j QY.*TF&]oeΖZ;8w!CbEv||]B-HP4лl)L哧b]@2*kxB5™犞-z۽'H A=;xm\ŃآRKS;{JP *=+9M_jѫh#؀\6{7oy|Ɗ- DhhX[ԣ^l 7'vY4a?}- ˛ΎWWȓM@0pveA +Sck/cL3RmW!UڲH:?!>ƷTq9HK%{ɪ"wPTD21B̼ozc( hoTj.RIl 2Q>f&8HE8XszxZw*ZQ pyM*5e*"mU'w'_#\h3^*$˚㔱UfaUwFJmLbf‰|ȩbҁC1-'!RVjz'K-IU'ʄ:ʹBWuu9'|fΙ3 A* +*"A1$~᭹jZ3]]}޻~2z?P4a8]d iRB1LD"D'dnru&FJBvbX*A"\MI?NP,6Tè4#(Xl_c2Kn X2^L[)@!ot!,p^?Pf0{n,ĔH$ߧU @C(8-#ZsW]xt/lQ#f{fJuӐgHh[H><r>"98#`|!ך(~HpUޠL1iIbI6C-WP?qxh_Pb&c m>$lnOE,' <8sຝJ̏Q).Rd~馑# 9@UGla Xh,8l Cb h!`cPy$qԀ2b< +&h3@jйPAL*&0@B@˂tҒ 8 `DsDŽgQ.KjMYWFszW/DJk9tZ,xQ/b`Ct[2'S110nkvgF=C`(51r ɔv|c +[P sE7ؼ0!G%ٜVkpS`bö-4b;,! +E5 :@(`T|n&?̔x}OA, jT=NWRd t_"˅P( +`@!Ws +"r0P$5cnk& ;hFt؆a8/e%n,?4"a; ,|ŊNFC_12ZT@: coPSyO(֤JP&65r p,FE +a=x܍dA/FK;bvOtO\H0 6<#ñx:Pp$O|IvѐClN}Ms\f2}T @ yHN +'~FndJK\ H2:kVQ5Ձ ` Y%Ai]?X dhB-;3&'A棠>Jy/38*֭~ D2mp c +bLR4ܘ>($.|>yP4=}2@ca6AA&0ΪAQtR{<!WHRlWė's +@_D +5<"$ +ټ/Zk=n泖lh #mnܨ[dOpx\-(2D%Bs`QJͶ8]x/*ENzxQOy FMc_Ȩx5b#^T\>p}#HO¦/ `8]O#50uv\>`3#a(bP- 6?nTA|zE`Ĭُ#aGpp0Ȑ4CL`&8a7 9KOzQ_ [`%WF\aoX `-bU`֒ӭ&G$42ZDIjۍccA y(-UCLXèwPQ X$ 4,^ 782Vҍ,'lnOHĴG8!LqN܎ic؆X +ewPU`TgKa,_/$;g`N {B:&AVl1Hv␓ 7XcA@ p%XD`?xA4 +M4tc@nNjF*X4jMY{ZهFA[xHJnz)7ad:̗#d9" 7,!d@HB QGBvTwHo@$2`XFVFiMOCTcȾ{^{Aē^`τq)1uvUG@ +XR(N0N{v@74EnN²4CF $ _E ύLtR;%$8̤ lv, R3(qykI*# 8LA&H0K1t`f"3⢀]005=(|֡eq}7,D.eE2ܾhg:^D.KlP>*J9h=?˜jX3WNZIVApbXfmmC˰ eQucnpkA ÆG-<me~XpP#v$ >C0{ .Rnz|&L_7}#N l9,BpsP;xMFFlY +пXkPhR eAvCj]dD$"Е~aA/b` \<ZRJT1,R5Ƽw$ikA#XjpCa̓- .)%VU 9cLdT5%s A 30E3J#"? {X@#`-蓑&7tP8!bp SY\QQҏACt~꘏oNvI3(!yp~.íu>mE^lx|뮰eMX]ҬR> +@xp,%v4wtMam5qTWqrBܼT/1b?=ZӀ/ 塛GU9<0\8 5Dkྱ)' 6  Z@RoL_^4N* )=zSP@2.7߅@˅wXsG~2b!. t7[@˒r +a+ą,Vy +\( 8FUFPЦ蠟J 3#E ^H>2G"~&LgL(Šs oސ4jWIK҃Z'\XI+KUӑܻHB Q9{Psc$~l`pc#%ddUNp,^K^57&V0Z֪4Ç0 Fgq*ms +N< +b}h܏y&Am.7x!d2yl Y@AP(W QDA]a:aK:[ƍa +|Z2p.249>6IX)!Gx<GH"5Rm%!3 &&q泬R f됔_ZJQUNM +:l )DX;a/Z]Lڗ'亐,ЖUJ(_B<)Wi,JM&4 O@Ժ +0I15+eP4*Bp>ʄÍA1AU91 *<  d1dof`1/p>8!0ޒb:rn-C|Dx4Q(}hr=Tr,Qq +A5@'C|!$x?,>d!6Zc*V6lGH@ G {gs8W`И[΂$jփ#_mTz9wP!zJBA4j~RJux#Bbx"TTc % rYnj 3&e`DnD䍊b|B3.kL08dO)>< +0"M>9!g)A\γflZLMGk^-Mz3H(J)5!9]B:TBIE/4JIZ "mB  }6)fQxM myh3Z+3svTPERfKLs"t܃⢁B(-$QZNvaQfGN: {yOi(!-L.d|A(U`95{|rܬJa4EJmqӚSj0,S + 'cʒH,O'Jn +"ύY(.e.ZQFB )&!&|:o|>3Hgxim'[h +T~/}a|k&[;Ě +t4=`"BFe@t\քŨíf\`at$}Am(h \$kQiRflz[\Ι%?r֡fDG-2.jbhwV[Kb>9k B\ + >1&H oAJSA79iIn-c! +,2)sq/;+tG+Zsv \l$bQz-h66^]6K0\pV YT)蛵ulfRO4!H@K]LLA2D{2[jy:.̝.Ν2[kBeTThCFbG+TymJ3FRAJGm93mh=̞Q^6ԼD\NGY_.\66幝bbXsOL )Z>#@/F4.TERnF--O%Z TaylP[,LHVWD*V~{7<6~if粒xkl˅Dstqrq.,B"q)=&jŹ;3d)W&fSc8B&N$'O''') k+RvNʹdc4s"dwaj~)s6(MeqH*) 2Qi.++TPip=p+yG:Q#5gRvDLE&A2̴Q[IVo"sIZ;͟OɌ&[K{ע9 dHFݭ3G}zVhAwÔ-\*NHuKB΋EI MYjW+nटOHӔ\Hwwznܺ~;(oe•' F}HχB~m~^M[쒘]6;ǔ(p gˍ̟/ vNݻw1[W'۷[8w?͌"Xq2-L,Ϝj\V9:ynLҚ?:8t-?*檋GO\3]>'!.ɀ5iuMSBƈV@>⌖m-]4mTcԸXO6ܜQ(Lh Z6jS[?yUpz=ݮ̜\LȍDA:F*yDaDwzvejs?U=g3RT~ `-[G[a* %ROJaƨ >P(\p?lFOr/ٳ ^[Nd;Ze<)ONW9m*dc>"K3εIvOx!?lVYb ++!Z`~u5ݹSLN*yʚ@=ZszmJy8ً56Bg2#4c*-qUVHzmp(^]D95շJmTBDTHbjdg6әID3DJ/饩7}4`L0[zGgZoh}6w~˅Cw6Vfv66o {c259-.{kO[=1toŬL g&-Oʨlfά_d:oEͭ_[De˳id~R+͋Hf0S F|CE8kr[4@8_4]L)Φ:23ܺ] #e300 V&wr2[cV/%*3S|zRu0Fi.Z +*X;ঌמhΟ^߹{7jtvW/]~mmu.p9.6j,])]f6?yd ! Vv.RXU7&vVBL +z +YY-JTs=8yOǀ?1?BsWsm$:۱N{~+ءe`B:HE]nuRzLII%S͵`1W_>jI|X*&f汋_[<~\'=kW.&ₜ:Ջw>!zcA@RmL9v?}xZ2W/>߽kzsgwo?߀vncbq4={%R+;heŨ=P_U+,N^µʰG]\`J7'3lu:I)Q +s\rbeP""B-U/ + J-J>m1~%ccK3J|XsW[P.oL+^o\=rWkpFqLʙ 5;d ^SٕSs8V]l\n2?+~oun`b5&>jv"!^ϾZj-PJ Unla|cz{.1=6or0'3GkxzqdOZ|E{h=ˇozub+]EZktva1yT̐j T,IFѲ\{;S JA℘Pxm=+߼|~8;ĭYMkBu.D(`R,M'|nNS7:W +Di|ߡ* +%W[kzeV)lF=ެ-k^]ܽwDc#흹9U$RlSpR\Όgαk91&'RwOO]LԌe(vYuaߐ?D&#jR0<{0ZMR+k;׌֢!hHjZN ()$Q3=d| r)1#%FNLVY)MS޵TY8[ll:+ޛܾtaWÙjs;UkNu-/frac^"d[[ g ^|r5U]پ^8ƚZFI()6R%/*ёL$]AI Ԗ0 +RGb1A@`KMNvB^F#E«Z4E~2 FrJap8B/:8$5MˉZYB$iveb>chIRo7VXR ]5<$\.} G*|2&"Ts~2#Er^/Ϗ/ӋKL6.NEK^$&te5Y)kQ144%r9٨8sW.߷QZ@1ZSTxfLF !Qr*V@o, #O]nm,)5ыH₄Lf$Vfz/l#Ӟ9{#9BQbܯmjy/-ӂYl]` )g! eZSri)Ɋf>ݙS=ԡ⅀#LRknWQ=iN\ʈ (JԖk3'3O +2o|dQ5x(ǗwfTh*ŹQ` IזyF=7NxA!I-X +]B*|<d^LsRgL?Q9$ڥyLp||ԭ G.k(B %s`F11(ς$y*Dfe%cח.d #Lӥ\׽BklwCK-jfZ7+3bƛ lEHUlb/YIVXqo80#Xz<Fc6#F761ΨpqaL5uVB@c{O_|g~埿?G◿{}O5/8}ͥZc[hOo 6VrQJQ`l']t_\<|ffxgaurcwЩ3Wy?_M,*T\*]t7)1 +jn˩\~|y~˿o_ =G!QRR)ߘ!ŤYfTkz}v<w?7aX6T{^ 1Jlz`Ӊ|ep9jL|{oOzW~G~?<Ǯ %`r ,2bűHf4h66_>o?_i/1hXn1_"n/e)Zf"e)Y%Nm۾Gg܏:}R$5>uovo<˯ݏ?o|_G~WoZFT7+c^~=<_ 3/g~koW/Ǟ~Rbg3[/iZYZ[tS3?[|/ӿ<^0¤y-)Sk;p:v} ?s/>ҫo{׿|i_hG'L1 Tx۟;|mw=p߾g{/߽|~/އϟ=\M=9GFqȏwKǎt~艧z%|Ο~t~ofAIy5,67Nw= 7|o~-ǟ铿?'yl}0 2aJ|4xة+~녟WoO|}{BwKIMQkdvқ]=}/~/cOzFfS[\; V˜$kDb 3n9y~x߽oxW~~K|C;[ۊYM{l1vvW^~? x?_>g7>fy"J񅕝Sn9q‰^ٻ۷?x>?Wo>X$d7^^ŜIN{~~߿gϻg#nӲBHFCNbx79~ǿg~_O>~߼)GN{r4$RBl;zO|Ͻ싯_o)>賿ӿ~;O[YWo/[=AZKcF*Y]?rgz^O@߿<9" +K)$FǧW^}W7~ֿO7z<]v}XnP6cnwՙӋs+ۛq>|w\||]%4V$QG{|?@1xojjzzȑCGvWΝ?r}Wzkov}g6nf+X(nP1#3'o;].cׯ|x?罏>O W/ϬbTd-}ĕnsǟykǟ|_O^|<#ķ~7_>۾O=vLNkFv`s.ihQ.C{w~ /O~SO>ן|ﹻn{g{ gAh0RY9e+.jR 塎$C bc0Y"HZ2[JNq%h^8Y͊~ .r+D ;=AR* %0tHEKlY;uaepX:OCSa!^ ["Sl0s"$ +DAT;gQR䴚mGB&HDuu8(+f^D rrv20fJ ^̋~\Ib!L"<3FUJ4A(C8 k8pdJr5܈@9i5eɘ5]T$EO OITHS8ch'mQ?E)IZR,T #͉~妑1;B2 w F99oP:fwji^]5௬RA$ZӃD,ZZ788kh&%^|\GAoY&uIN'6<Ѝ8(BjZX=VgJ==A1pٌ|l<!"_X@χv|ed:d.̰I F0\O8N/5<pXȌ^LLg#-'ռvt4hs$9)cX!ͨM{Uӳbb$MK@H)O4+k8_ ƜWgϠTsTՊn.G/̲6KZ iR*ˉT}Gvn奁dNf1]GӜ #.9ZϏOmQO55 F*A$=Eu樋q1b38 1Z$ԂabVLZͅ*!JKx՚kclP"1~إS0RBh"ќ rH4LV&\T@ wق!<}b̡'}x̏|)W@AhtTHwd%-_(a{;f 9Д6֒mx#`ת >D: So$ ҙ{%O'`#wM\$BF;BvQoJvwS_y$$e :rE.ڗ3+NTN@pe"%6R"pc# !dXu5{cL?Z m/Q:9}!!^كVCc]~҂aa"A2uMn:AںPBB\-IǏ5hl,;nnp,;yOM\0c?8-  + FkƊ"r9BŁvpe2RWSjvڇ)c Fp`2A:yoh[^53hnaъdD>H'0H~`՚ᤢ(FQJg4C8.fI5`WPpp`>(%%3(/^@ +:BPty@06٤1pЎ}\Qb]ԻrbB0Ѧ{H0헃dBuby&hkmng|0)U&ĕM1;+/7f5/śZJJ2[- щ`sYQf5k^{괒Lm]}G`hC4zfuvxC4=ΕXc.jM`g=8Bhkg*`ۼz7W(dF$ \XVtuC (Li.bԁ>XڌVn$5QK| +8 +AHk6֒qÚ]~:D`$X7V\L1.=d :oO0H5Nkq&0Dm>1:`BHNlz~c A051R 6&F!Q +j>91*dfhQjK7dB-MH;\N%V.# \g$W]>V1[~L򾱑Ѡ?(bI%It0 TmhA*C0^\*M3zyKI)?mV$>=~~r6Q99p@#Un:+V2fNi%YFՅdO-f[[ gyVx,f ƚ +˵ŋF}^脠7ru1%2yk_NpF]O̎Y_ ,NoV9:\ambz=OZK@ G&7exe  UTc?Ky+{ǖN{/XBH{'.[dFv;N8$;{=wsθ_ I߃9/j3nQ2D{?1yX_1jl ࢊsJ< +0_Hv/3YD^$䆍^h AfKs~6Qz !eGښoq hl +8~Dڐ0 8PgJe1E>2jJ6|DIQA*  +QrApqK +Ƞ!ł{ynfGGJ8=l' )1&-:`zU^g𡚜 ltl[-Vet{&Z۴ +@wO@p!iIz.^Gؘ0JVʫb#P5JjeUVxcK 7 +Z5֞MPnir^a8<E{PHvg㞑Ԓm".8H;Ve,i-rq )/QHi1' %9yhF݅jfj+^;/:#h@rj}0V_vlUk3=@%J/x5o8mjVlLl,}Ctx".\),]k.2Xշ>' pQNL&^+v/J!*T) 06q/n,ggΏ\@JPsvU\yP~P<¬#MLBh A0xm$8JT: 5G̤9H~喋#`0!4^qpe. G$`9բu?xq"furm-&[溟 cRj*f~PMMJb$F+;^QZ8>D񡺕_4 +FiM"\UĄ^PRm?yO:15Sm-_*LʊU6νq6_d.>I.+hP0 A&.&n^.@X#"6Z-P^ڪk'Vμ Zc̋ +sQLW6=fG4ssgzS|<(:l6bppXKLGޯΟ*&%"BxQ`A.85_`)-[S0nSX8{w^/_3.<<`F{+!@DhcPn$4d@RЋfq 1RD;^^A n j'adĆmSzb3Lx @F)B`d p"FFDJ6'AlgWLRVFf.\\^yuvyj:enXpn$J !67IpQZ@G`bDʫpyG~V̭squvf" PK! 1RXuqZWbE)^0*St}-UZ\ظ/B\Ou-*ҳ I>:fF⨇vof 5d<2~hH-$껓Ș*.nߔ3@xՏ.k$&́gYD+e=>-K H= Hٚ%C1L(˜ +Z+іD/5\|RjnZt(A(&*Lq Aq.h+&sƠ ,^zU9'dLVOM\OnX88 !lLcԍ⪚]$B$?[nZd{<}fiS9{ӏI%0 Cxwx4z0> H#kΔ^j,RcJ6O1\L*z[8#ղbEn BSr6zA 5AO67.-\O7Wgέ#+ғr5q=d̯0pG#m;Wch40lN$7vY-u %2\4Kr9r1X2)33c9dGq{Y .8>hE HE5uN/;`KS/ +=c݅D$WȨ=t?;t~T$/Ey5IP}ӵ{DC$Th;ZZ2͇~T= P(f#$.Wbhب5d~MoԅP* H.\n+sqB)*-_66j݃:ppJ/vH A\sA݁b J%onȰ-BLYեU_IϠ5==?WZ%gfi9Uhn6 mK$L%p?\%v/8{m$JF{_FwN~g;kbK;[7ykSk$ tzp| 6/zPĘ;E_.tN eƻ*vcv1 +nTB0 RA O@8C C 9i.2^klK ?fn`B%xR[_JS QNZޝkD #=1vunÇkU1Ap M( +|xdD.7U 7AhFpg}Fp HpzENpeR8D(cfKO]>|P_@e (Tlse5 . /|b!+zM‚owm(Z1-Hg7Aϟ^lffYRͼ>ounDV(9UhI)-mRS!%zZSuss>JĝPc@3n<ŵbIT*pQ0,Qb侑h&0CvzNzQ ^~XNLŵxc'9g;^Jq7|,J4)z.}0P'!L(Dwڽޗc+CQ.8lo^I:\3-3sFna!6a/;=>Ivy|7 +DB0Tȉ* [!YG$,4V&'Sc^)Ff{ (Ǹ8)9=[_9{P;Qڒ8~"z$6OJPDhɣfbͻ+/o.ϟPd%[o~MOMR}9ڰMNˏzx0>A\87sW?Eh睘uƷRgbD]i C4(m>Zx=E#^l-0e(QH 3QȬYM4NwnM\)+_'&lKTJ Y9aಃ"AZ%>LTV֎G:'_ywguv/}՟)Bd,c>X8PǛQƉA7J^D>"E\,/ +fTj:Ym19 l[`6*&Nޚغ[_exJuO}e 'Rn0YJ!' lׇr`vŌ/R grť;ˇ<ꞼhmF-Ɵ/ 5j}6s!\Xټp񨘣[W96~chRJѱhq.S_"GHJ:{m37k+U7gO PPv&Q_ YH *#.ۙ'=BD +x}|mbn׻1Ie:TKKpv +f4#!'50\J{KMYD)\LZ6@r-;6sȆJ` I>=Ko9verCO![0kh?d8~$LHeSM; gNu*Њ\ݣm/V{R6_[=U7ͣo/_uFxu@tjāpCєLRZ1Jza,L>&ds4{:=kU7r& e*踔a\ &ʤ}n ̑w"L2NXEZ TL<}|D>NզwciL޸wWB%)1!FuW.V *Tƕ"))M2Li,Ꞁw~؇f/r7BpE3ɍ+Vm-H[kg>ЈW0*u:Rm59瀄^+[vk0vIQBg>qj'\u'}xw?$ hȮbi_I66$ `HAcUS]N\? +%<U-5o8{RBV07α#:܁б>EarA t4))%DvnWY*w*Gd읹wǥڽŇYs/'7nV+S\X?N ލZbx]iDj,>0J!*ĆѱM+ɋՅ3Vs38)gHkܴ rghǨ% +nR֪Do%aփ@af:usp9;᪙n-\{'&H8օ09 6ZZbSF$\T5(x;0{aQ3m`R(ބIǽA:Zx绗=y5VS7ȡ*9ආS[T"):1{ap0>J2BuFm~9\ko\]8z|,.BY9 4)X(c[#!>[q"BF(1' ./(.~XFf)=;vJN#$Uc$2ag.-(@}}}ި I8;~2v|ovQv/P0E…pi#tR%[;Z#p1FVt)Zz0LѨ>dGG %&‡m0A93\^<~xƋݵrgg̣X>x!\UcU#,.:wo>ٟ^W.~όB fc[Wߛ}D^6gOrnXs +lBLU96^&I+u7Ͽ-^ *T+M-\W3+*y)1Y㞠(u4o0l1aI1`0%Z^>d"DJ Қp! hrٱaf V Eyq3bg&)ey)$. ڇ$)$E2+PN/L&K„i&>I6ksv71tTԒTc0X"U&Y-J+))Q¼UeK4r W2s|t!ŻSv7x!0 ?8c&+{@Wg cBɕ Vi*SƊK$DFExe>7&KZYৢR`̄2nDNW4ȂsVw/ݟ^ݻqg\Z=}|1XDŽh~~; )gH3\-L^|ͪy=?lzͱۜ9u˯Z#b(6noFɃjeSrXt0(=HbuQ :@lbr xk0{X#5OFfb0O ))1H@QesCgߜX}K -;*l ;F?! B)o`d٦PQOLd&v'fXG|>@NH$ Τ>SĄgubj1 r#ePz (b֬I0eqMH5?ȦĴ ex A(gBM%J8%t3Si̍ͬ;rGWL˪'&V{'fvn=q I%\(BtcŋZmgĤnH!T8we,RjLkof釠 Y=HdlLm vBa|)?~ͣ\Auh})\ؽnK^xKXsc!FΞ{{w? +Koomb2WxUINFrbln%+tŏ 0&ӋgؽQfW>pvP ZQvb؎C4&]>wwX$Dh|nN: atHBBJ#j`f"*陮Ҩqd-Ya9C> +bf`oA gxsx%Tp ֻCi} ٠H9p 9$zm&PɃF}3|\*H]I+\' +1˵I1!մO-^hvHQ6 )cFh 9P|&øD@T gLq.n7TR-)ĒccJR׈~;o fVz8aFq'JiA+PbCSA'3aMg@5soMm^MOllZ4q+? BMxX%E {dffA21R֨L\s:Z^0r3$HqiuN/;~vۅnCf_}+'nXR).ӏ~£. +Zj{nHz)NFh>`jw.e96t*?A3xJnyKA?@%IcDb|OLN6i!n4]>RglP(?adcc\ qI(ZAVy A& *V\l3/DRÌPu0"8-%))L5y#d#47BE%6Yn aEo@~0k +$sZuHV盂rZ_!&&hlZZ5I. K@p(Hh-`/8{4v 7`T?ͰO>8 t8a@#ggCĽ~R6 +4(LX +y5 ve~gc~D!ˆ(J%$ (KRC9|xrAr U9!<KtUKI3^BNMLόX ID #D|3dH6i1 5"c +lBJN728J+B/* +e[ PFjm]%#D$7W?,·'<<Kh2A >uF̢3;?| /"t49ryqy~Xc:,$fĤhBtu>Q] tWiF(Z7 W6SV~j lA>ď9cWe4-g=A-@DA!Fa9:ADj[ LFC┉QRJȌPT^q;8CZZLd>رs:ёA`cxp֒T{k6>G1? M8m0tFmlN* O!i/ cB*| 1\Lj#l9>7:2 GKzd Њ!$)rI iSן|+8@"RL %$/Pa}ۻ.O0+$մ 'Y cB̳b)5Ex{=.I`d\0p"E%+2׀4h +(1u?jF=IZ`Z6sWpA[YKk݃{8dgƸx*E^za ߍdm D(!VhӓУALRP)TDF*$] < R) +n/Oe^Iadc^/r'D@ pv~_2r6^) #=>j=<ⷍQDA燎?7qӼcTxưX.Adl#}9>2iuF>'-y|&)iPnMadfGa2(P=>9yRR&h7iY DJˍH2CK*q4 G Hv $ /b45aqc*W/V#ق+܈5T=drlZ[޼}>0lC#GMMW@y ZMEdyF~3IR,ORtQd blxا@4=$"1 +zق%Bf2oE4vOPF( ǃhZRL-h P(i/2J¹jWe3*I4b%Y2X>"AKUrP4Y6uUQA$lۂ(a +b@~? 2 >7w}"7z|{ti/ZJZaQjK 󝁾~M AJب5P& DQDEgiIF2Cc8>:< 6D 0z(;D*cCt4~ BXhoLrl+8Wsymn7n_=1٬ZrB1Or\(IrBd^gR(d6j%uy2⍝}iՍr%;<Bq]" + wTSU))۳olxs߽_~~wm,R VVy(E%íj;42q&,Q> X5?[Y[TG>dd;0qYl|fpwC hV*r1 A86*g϶&'CQkec)8 A!J4(A'r2ebRX +c; ۳3t+nOg_ʝw_x|ԍK cxa>$MQͲ.?U5&+7?|񣃿|~oΎ YR>U"V$\ȍQ3Tf~"wr}΅Ӌwlv;8lUkdTWX4vݎĨm"gd>MrqC4OV +OF38ʞ81:8y125(_7cЩص;g&{gOvGw>zT6irCLͺWs™杣֣kOnN?/)Zt L"Jx' T+oݚ~Aӿ?~׷'Vm7r8 ht3S9aeτb̑ѵTVݿ|}YM!].χ0ip} 5Y5X ɞ]yYngm̋7w?~~n+ngq])RΈdՇgXT?8*KI+-͓//ŷoM~/?8^K_wv[|>nRIHfOZ L&*{u-OW. ?o>~^_zn6a?(ln2=P"/ίCg'_9x3HQGe&+&3ŷԍe_y7?osui{6ʲ G" !#܊ELן;>{/?Ͽ{/oG׾+W P9!o qͨ,Җ˅>Z/ן_?{G~~_~~~wv~oӽ[z>8@کlk ݯ??^W_}on|owoo?}'FY `pB["7[Qo;lХnY"/-`'O?{uן^_>OO݃t*M„ĪW:ijsw߻\/^{~O˟ݭw5{oq=?)=ܕã޿+K?\K?_z7>_t';wbE73!h%3L%+jD7ItӳKKۗ|_s_ k?:UhM xLH]fR(L%D,;;^^݃op_?os_}w_xx^HYN&tٲxib}u(B+?r'eLY*BX7.&n<z>On~Gw^8u~>XV""E52 Й1osOƉqkI_\)>8vƵ׮-vӓƩNdM( \T+L9Mnu,vz&wkv+~ܿ^Ot^َ[n7jrr&iݚ(8J/al*/~|{Ξ[u U1|`<1/ݜ6:Z^Xݫ~`'߸xc6[3^h]Yɶ*K5RS+݋{,qvcw$ɲ47Y]"keZk͵UFDFjUJWgOwMOun.w٥X,HCc X HDp{>{w|x(@#H!By(,e,Tzs^K0Q՘|ՃSۓqk-_SD2&,kceB`,o7?_O7~oZ~y #ʪꠤʼn5F_&r0vcz{/?_G{Z؆vk;\啐UCiבG0Ց'#h:&ѲLGVF.VtlJSYXV!Z;lƓf}VܱNkz`Uot5HJ%[3_KaXluW#Qz8u ~0U,QVp>A ,v&U`4Ů-ccz}qoՇ>;xy8ikΈs/)vS,W q-Lrf bckRu $TLNHNaZ, jU׎Z~3aHf4j#'4e͐U0*``RȦ A^8vEXյ,-3j~v{o9;j{^;[\E@:kLgbzcAR[L*ޱj[ܰ[bxs9x>スܹ9yh\ q."ɲ [yfqJ=/;H5VClɺۯ۷-&-Eep. izm].^5[G_{+JYDNnwaw֪uM&J4s\ +[D- q#I\k$b$JUʅ +("#"1TWqeaJ͉q`F% +v>^"#XV2ksj(eEL|$pj{=n(h^SUbnrbOv5!56RrgJJ$_M4:r)F*F%Jϳ8)6qʸ2GWB4F§n#/[c+Z:/2J1ydTQ-$C 7VrUk>z3X)>zvw.{ڷ3̝4U&lA?ʐEFZTϗOn7)k7EϢf˗xA}wxR, ;ԍF6S)p6֦pczTfZ>#`5/o=:c %yC5U5Km2_CV}w僻@Er>Z!R %{.6%OB+vQ.`$9R9!՝ƺ>LY/;3zh.pr*-x͹SLZ/{_J} 3J2tLvp \Tt +DGD9D!J3=Z;_!mhs@3>g!0nrf!S%.J:OUH嫢5ጾ` 2ZOzua|P0Ì1$F #iHcVw%oBq2r;yGٻ/fhݻbS3,5T\&k%vԶU?CTQacwe34'w,S,LZe2霕4Ί%7(]p$>yapJ:Z'/Gf/6欵uUu2+S˜r߀<IG vcuŻ;FDotlCTDImEӋg v֏|yݛǿ(r>!Y0y{o09歾,x{B W|ŋ߉N)0ӽog<u6Ψv҅,$t*ll D0Yw%BP1"™0kEMeD\ 8{Jf8բQ) A2Wew){S[FA)35}Ψ<5ZX{'&u-1㭊LMtE.65hy :;z|@)MWA!PMZ\Y\(Vu;ɝܭSLJCJRѤRנFe$SW]]5OF}34Z @ J@ +A碌~e!dz2ANQյx?pPwv_+Ѫ‡7]qh΅(O%9zsVmq "͞#.:$ jkkZeK]yiqW'OE6rVI#:y_/Қ^o, y_tNћ'ҭ.A2e}#br!~jv2_1+(e +4hݢlU[W'VXFQYQ 2W&D-Q>. bz^3~sU0 {_/avj zы?}㷝|#՚÷O}|9<5R%0Vg\, +!=&Auff*0i(䬅'\QF a#@ ژJֆ+߅d5 1HH=BiqYs ˒9>{ON1YcOE3}xѳͻ?>ͽf{o +QhWk3$ycht_k]_^o`b舘kMq{oŗ 昲f7=oLw^>r LR!%%{oiC+q4<-`v+DTaB*\{Yuw j6jMV@Y_~yxe?UW촏Zˇ{=Y~ksxWh oۣ0oML3Ǭ%TP E|̞p~dj|qZ0(@d@}_U<ə\m#'c}t\fh+-Lhk`.XPΕN1$ NE{ od_wx;Y;<^P`G?̯=xG~%TT6!UڜfZ k+rk,μ5"~ǹJUp'V@=w&17ыtV\֦WZu{_j8zL>9bFZ?zV 7z.[{v4_D:޾]5ۋ0aOw[97R,{{>D[j ,hUw@X{8=|sw 20ZGnF +V.H. +L1k;|J=J!O،9 'F0OܰwBLỈP QkFre<谈=,!@%pazR5z0j1Ôn Lް@Y; &nNzgqZ0O9^sZg="8~}."Z׻2>HAnHLicra K(lֵhN:1mLȓygW WكD*oN;OO{Okɓ +.K`EjvZ( :+0e6A}to':ßћر7E0^x{P;|7GP|/_f} ׏?S{wT^kFw/^lw`2:QZC0f#W6yB҉w꺘ai0^~xF#0?..ĄsF:2~z=e8};%3zPLAvyS]Ui3N6P &(Qnc5}`wF^.xCNhR\,N/>*T_ w. p:g/ Pj7Xo$b(eOE7Dĸ(?G7m`]uw%kTך1Y3[ C,HcXacBnB +WؠO?/1>otg@ΒԢ=MCA؁ +D)Uw$"|H*)ENmǮ/s)  D):}U<Ҡ +`򨦺}{hK+ +oNKakaԁ`J?< _B6^LjRNV &W:y*`G19W }^=`.,ֱ^ȩNmxOpE4bl]犿w{{[{0,DHmu8x3߽v^}v5:}X<"F˔u^J&.(9t{ڻ/.B:鼈qrl +n^ +@oc69!9P!ԣW~9*z=+elE*yPW ȒL +v3]@Ũ")ً}/Gʍcn4 Y(11.w_M[ v Tc֞f:ߐr[9 qYbp7jG1.hUknp~7U]TlEp DF7> ReIxZFm^CZ!{a^ad=%kic튺]ڒUۛ9!٨kV]|wN7ε.dw+L(F>ϥ`-*R6ƘHi_?RE`9R + b /6h8n4֐ѓ]?mB9ܓAqgIUt\S|ݺ;DoBpu ! +M[ ctަ|ֈT&LU +e@HK ! iu**7,-ݍN˱=y?,n~ tǕƮ~ltqx.izպ9ԁ +~{L\-nє7 \ܧwJ?4-cRB`PAi[DveŐL&RQT4$wLjk[$=,sm4>u2ZJ:'7%Z,郯W/g׿|QP7)2ܖwU/1֯yPdwPIkw{o;߂aUr$&Ğ,Y{U<g_+cO`[}\}0KyY&Lx̅P.QAL9kRingw3a=Ҟ~V^Srݭ-;OoDGO`d?YjbU[~rMN1 m;C(_E*4UGIsLeҙTJT'()T=8'vZvTPB5*d%QL2fczNRG!"u%ynO.p'-1[o tEpYkQ>߿pN!-?^\} CtIp꼷zYcR.p ZtS!XqڙĪ7Q{ܪpa"Y#A89zJ:BStAH4Ǵ?) +,+ÇrQnj\ +V)-JF +4%3)v)V=s8QE1Md;@Zd!4r]AfieXU{t<V˸-HYNQs?%>}&דVɺ]a<T]oQgjƶN>t/P'OuDEGk\ޱ\_v/^W 1!U w}8z7PSR%*$*kѓ-0.@{ 0[g~}:}I(2s@p7' &=t?>PK,z~+\mTIYѲDǘY?MY#ţxt8fh{YԸ!)d;mf +DhK{B'ˬ9S5h0 k#^3j'S;2HeЄ +6.mqWMuڬNΑ߻sDN1xYy"tmY>Ȱ?U d +eFYw6}G* @X P+(+4;% ~ʘce05b>b6jndRxWrFn:IYBz|̃EvYͩ)uMo*$!b.2ED{[ Af"Hc" e3G@jj9' +S7Tp#nBt'ד͂Tm(LS^e(|>ǒ +Z#[dJT! +UZp#Uq-9+P\A\5JI7hf1Qfd)ə=A!B˕=z'ܰA2E $ +,Xe:)6J{gv|h +YouyuXc< 0dZn#T<ԗ S(mP dHvȱيʩ}3!y\T\Ҹ܃n)hC@O(Wń@cYI]\jj:܄7Od +8)D8QFm-:SHRP!~IPk"ކÂ)Jn1Jj"9D!{TOxgGք&Xe ͈0acQypbD8WHfQ7 +aS8BCM4Z*щCJQ! +S6 +,"wӸh0""(2}oŒT= 7z|Rf"bA jb N|v ?[]F |kf]Ѳ:z`w۠O5VE>MP BTP౲>1F9 *a:Be7klBjTq+@C^(")ZLږY;s}֏XkI(|{:ڔ:d0hM}pen 734.ųE!) ෾}6.q +! 0*)1Vq&d Շ*p62Bs$WJ2GC'=4B6( MHemƌ)l;M&yDj}u7/k_ e ^)fMdRlhtzy| +Pܭ;;g 0[a.v '^,$ xk P ӥ-҈}Z nbWSO=lH Ji@T'5)A-B'-*Pc'-7a.V);?Q& "pWz 1{;ofqQƃ?V;qboaxR72mR 7Ɯ5GưFOcoWe.Dab +]m%:E0~N `n +KflYTb]@n>vˏ$[AqX鴯vhJ; [ּ3&%~z+''{9 fLәrg+fѤɵ$kE- $TfZуN63FyP4FmdYb$jhkc98Pe.=g +|0MTY,` !ڍ$s3G,^QTGp @]K_ tY-Q!)%kV(+%R+}L"/ +79YV{Wx"l5"^*b҈))D*P+9 +[.w()Εո`yhЉv8eswS4Odw\!uxs8{6c2 ń v78gN +M TRm'#B!b`OP[H:KL)O Q ȂE{\<͞«րE@u!TN*TWh']n孻4WXm }'CAA-SSE&00$>,@aj<]tZ1Ʒ)vlc,]`(6[wʀ=NhyTN]=]$]})f^AcHk|6ҨQ6ƄA묽YgtrIha(UBv@td%`2ޚ`3BDg+JY#BcגkPP/9N\'VJmh=LyEM0\Yi6ĵ9c9HevTA3&oVغ`IJ+PUZa˄I +UY08gijv2^)H.FvGanPr[GE4?5whNU5^ +#VE|2Br`wKΜr4/WٲZ&<[1\P?eF`P#0N,~1Ɍfɝ2X2rA{74.yk!:;4.`ahL VbppgC1X|'yx=wRFO"iSjRH0.N"EekLj9\÷?j x*RK")SnfVWbzk}^dY&tXsi֏` %0@H[vZu%7n}xvRbJkij+Ze+VlA 6@ T(êhV8@5Kn\v晢1-9{HodĻiWAy 5`dٙT9?#)@! ,]`ͪNqh.6@Fs|@J"ۄ(hrr)h_xHN%5=.#:D^w7JwrK M(gcѧ1KDAtEc| E6."U1&!6lToAEdq/Q^5ce`A%y=SA ,1irAx|O9H1@1Btf,OI I8 +(5*wτ`Qɓy)Sy{L}|\ fyB FmmqFxІJLVSV$#B OrjrLY1iO0:P9K#6cLCh*%܃$)1PS RbҐ5wRrp@uF!4H0g H> +<%KfA)s {pvoCH[[ĭMc. _ He 3ʲwRD=$p@6+mTXr9WRyLI[>[qrX@I-c\G[88GCL2.ʚS+ջe9M:)ȇ,ƅ# TɼgdPt<1.2^: 7ϕYs`[a#(=XI7m>"[g9'lDP \)FĎ_'b(  *7sgͻ; #n"wI +R#gHZBȝ;L)A@ܢbGO|NNL ?vtfL49vicU6dETBnfQe$ἳxv^o!B8::&*JjCYl䘟}-ɤԂOϑ>eƍ7zɘcp0Jj|#hwS42YV' b8r{*Y"R%VIN9 6onLZ޷{/h?x{ϭLԝ W$|ޜVA&ыFVLs0l|r :u @OPKRiJJ@zt) H/Dž +Kl {;%2T) +*`E+xWg#nUE,z )+Rqm1MAskz(u4o&8mԹS0d,f|Y+pJ}} ZQꛈ] C;iNQCqGr;E{};):ڠ3z^Cm #R,ַ8"ĪHEm4}]LiY->K +FI(ʴVYOijH4jGz|'6yq KdacV]NUG .eܦu\+-5Z|E)f3d<"+6"ˤ',n"<]7R4Qss1:ỦjU >[]Up1g[gI9*?neZ-KJs5乪phU >pBu. Kx w q۝˝柉fuށc~ȏmi ?ضc~ȏmi ?ضc~ȏmi ?ضc~ȏmi ?ضc~ȏmi ?ضc~ȏmi ?aM8tx^ (cʡKl_ٺo^haA #22(V"P +^vy/d~ ٵBvg]/d~ ٵBvg]/d~ ٵBvg]/d~ ٵBvg]/d~ ٵBvg]/d~ ٵBvg]/d~ ٵBvg]#_|gܞ||ӷ/O^ݞo?뫓_V&_\>'W7ߟ_ ӓV*~"=hVn?rp; +>~ (&ФƇ%Q}]>t:a.mG 놦6zh~pu ?Z}~}oOn^zɭlUor} +~r&[U//蟾O<|\~Ϗr NdϗWCwo^l\oNfO8xvsv%O/_|>Z}SW~tml_vzo9j?:\J~:vb;g߿xI`B&==}˹[Ols2s~MWws{rԤMɫ?ޜgW٨{9MשC7\^oח==q[VلaW^<䍳mxl pί6=g7'78Q7g9ytcQ-[QKW;Nϯr&xy^_~u~y>W^ܫ ++ګ{pGU S|~s"__=*pcadu:?>9'ϮzCXmV*R|t*+Hln}v &rT'逖"|w-7PIBWOu(`os2eSgg6_ų)?uvpo_‡w]1 }yv?:l^lvO^ݞ"ǁwȍ#O^y~rzf.p2h%]O{z}q}o?xYd;_!n__ :+Jl!^}6kqcGb)[Z;Ix*Ͼ2xlIG%uuvNv}FM]zwʮЄoQ߫wb'7/.n7w=>$"C½G$qOwl~o6_/NfٳN`WI=g7ߟa'HU{G<^__<9;;u;?9"em/d>QW=;870m~s??~&ǖ9=ٞݧ|}Q[/uӴݥi.ql=Mۡh_ؤ`nvZ?_#` \qپSQqkA k^Wggˁw_nlE.]yKZ%ms_9>H(JzyqrzvyvuˇǓ8d*gM~tms(?~jcC!'򪍃7]q@"[ jR4هB6_{r6ރ}["T^ͫw/?mWks>} Wg'ŋwՀ;_uMv/mpm␟_\l{q+:䗍ۓ>뺺OO__~{tQՐMz~s}9OX,/g6uOrTxǢ@%lхlbهj<.5nfs\~g?gK~J9}t3表J>pf#qJhG8s}c8KdQlk;NO]+z(t7v=-rkvOGn?[1*ȂXoGYb}sN/ym#۞=1۞Fo [n=uے=u{Hm/ԭv}spDm%_EJ겿"&qЇ/>oKRܖlpۼɾ˝0xj.i_e'jܜ]^mZ.Z.wmWΞ_1t}n/Nn?ۂW#>x37ry>%f%D~Dzy)l]fWDU):e~E.͚~ wΈ?j+;> ?[+?{o}/yam{&<оd+ss}{.T{c湈_mWz2hmi2fwׯΞ^_~9[%rvqqӦ8ŭp7^|؝q-};߷foW7Mp#؛&ICé7ggWtןx~}qvٳOoNgӳ Vjݫw ;׷oZߵY>;Jy sϱ3rl]1٩ʛ?R߆<٭ˁwʽaExv|v#ewޘ79{cޘ79k9fQcYvhsv[sc}PCLzY]f<ݲ_/ϱ/ϱ+Ǵ9e+@x}mwl~ov6'osY螅Y?KwnK +v{>V螺:}4խWzT>}UwT~OC}}ڸ׾pNwR8NLa6}Z. ٸE0{n[܌g[QFʕtw?G,܍G^{0Jܾx?R]`m.܇J6C|tx%??"ӞWlC2{?'찒cW,7؛?]{'[ ڃ}u[TZ;"Qw2J6H>Ƶ;yuǛtsq{6/] uNR-pcvZ^ k{-pq>mWj?oN6{uCXmVz`ztz+ &#r'U)vxMk߾Ʈܘ}_[ƽѴ߬^]/D!.t°oQ\PJ=Nvr?Sh;1=~ >}$78'g_l#-Lof۝d.:>zu{N8wK07*|[6~(k۾ɿ=fn\,Biq(lOn/i'QsmYZgJ>w րg{J~\~qr6SϞY螅DzY趤`׹gǹ;iA76{h81UJd997#lɾi5>N;\i{$ЭϺ +d2?~ }(G/OUy'xo,o 0ʣ 4..)r'摻hjn~zq}j=уZqn-:q{%)EGk%wQ~ujQi4ʇv8pg7~~9;x}0|/%Vq?Y=_z4.|8jׯ.[r\ }P6׃Mj6oڐVQ'?2Ƶi]jC^ M㚡o]V !!:׉fxBS7M'HVߜtԹ iR3KtsSׯ֧A~SnH60šk7]/ģ'(~X=/GΧa8;]8 +MiC !%t-^B\air,Q^7}kVVAa|M]L(6Fܤص[B: cKF&{u+X^&^Â,?>t%@?S6ѻA 򥡓g Wz1`k/ʊZƦ-Kpe8Æ$/ ` #Hc+>)EIZx\/ӑz\#8yIiNB}E +%x@Z,KD!~EC[#,)p M3(A'qh[`0I P\AAS?6=}$xF66zJ^#x):`pZ!mA1@0r;dZNS &rO .Fy"Z¥ F Xeeff2ʌ9Ch7J۞HvKP|S =9)2 B}PHRoyYdX\^]6aX +ФN?7nLytْ:R:AA #qX`HD D2Hb7yV .39{{h{Op;`vr5|z(5;T'q)nb={?`@Dz0=u(@?CGR>p#ˠa.*)v1˩!'(롂) +l% BOW=S@:+FXTjoZ<HQM>aJ|6/1(q6ok .1yؾ(z6!O+!ccV~xGh?x?L$j) qrCTr`6P::یv)b2L t_}ڡc .!P3 gAs[<=f:pՁLZw;A;DWJ= R i@ EjT(`=b;A<2$M9[N+'# SVkilTuH_2p:)N4!{y Es|SXcөamy,l2zȬ{~ ˄eӆ+Z#Hub2 ZbE# <w&>M"ܷ{MM,vA[=`DFxOS\#pJ-Z@Dۆe]^{g2o590kHC 5[|OxBhɆۊN>y?Rcuɿh1Sv*vjO*xHM% +tfBMт́ˉޫPI9lA%.E,#e`[8N, &YUGVpk\\Kbk@VӜ#u4~֘D#PpC +ArDLgbm)Qo DX8fZOرŮ.}/AF" Hd +!0XM@r ҉ǃKl1[zՌAȂ2Tvp}]+b?TY4+m^ ӝ9!.੢%ؔ} B%95ܽliA3trqSQ#QX Y|kO|';҇r=D, +>Msj Qa?:@V{I҇ s\8BI==ur5И e.j|G00EԏyR>٩GMv$~5wEnutD^jsg !Gk<9$ǐ_ u鷀_XlNΌ^ ^fYdJY`?1``h ٩ #<4q q.t'DkԢ;gT| M|l$ʢgk_zyo!\y:(e4T:%P̅u*Q'IFMP#L I-|L{u%uxJ脁)JF~PE?!Pw:Lr؛I}T9-8rihLJ q.g>&cr1 e 5u lxwXwz^OiЋeO:qzNa.՞'R, -^DbC}hyʚ؏u"0h Ff~s_ZzHrR*Bb|ڨe,TYc +Қs4kY'y3!#Й hZzrJq 3\, B7"!e`,hiHď0621J#/<ް xޚx yuPWa!4fSAʇ{DB< +tY9ԚQc 1^@Kg|@UŁ_'iO(UsMR+%EƳ_ rO?Z'fEicmgR Pӈ]Ś.3ߙ-h8GHF/SOCCaPP4ĝT m2v@^&qGvAB}W?PbzkTvB' M)vu%l=S/`26Ac(֐&BT]˼jƙ' ;`/RӃ5\_`M:BӃ_d`ږڔ">D#R {Bg$<֩3\xv|J-'9h,o#!wdi[DXѥA" >stream +Hd͎%;)oi+X-`s2/E~5TNGDFcG+n%Pr-듟cc¬}n)`K?|chozLvcѣDS)c!kH}lfzs,Vzl-䪵r=J )53azͿ5L6 ~᩠'3[ +\{hs59j 1hF|NɡPN5hBi!,3rh;\oM Y:Rx-!Fl9\53YJ08V”k Do3{E,hfHjNKǴYrD8;EHZ+m80jmIFCkw5~"w `[`1]]Tlo#ߕ(iЪ]i!s˷, }SH}qf&wPK5M%TXl+$S-Ծq>Aq^1q, )}c)uc1NRQYK8OEq*E3 +"줣b^?ovPI1. +*Iasx8H^)Kpȓ6.x/%vLGpb$;hI9lgsQ˻UH6GX9XKu~p, ٱD> o\:F0`$2X60.f8HY2<[$Qbv3|`09P)de]SZo"r/;4?v/7ʢ+c:&#cpkHpi^w|'B)=9_RyʌU~P5pd C[jQ0 +JS(0\ue ;dnodJۈK9F*shb9MP]s4YfA#[ +1dY啩y!v27tp1n)&]LV͘xapRYíЭD>nYnAue@x=eӪ4jk_k +eOz9[h gV6M׍+v5pgs9 +7*QfR^jO^$߽l^-01笓Ũp*w?a &~1ĹftwVv u;7y}rl]S#vY=U@6*h<>pٓ\䇭%'?C{i:FN~:S]IWVwwΣ8 \t|J,j]j~8߸X9LCRigH^M%u]uOz}W*j$,Ըs eoK$|/zn +=@"T' WS  +C5H +e m"$D[s]&7}XYQ0\}]ҝ觵HH8n{63~-Ag .mW69hފRït7\Z52\eF Ȥz"K.SO}`o{4|[*[U;;Q#6PXOqJWN3]m(k膁hK9**ƑzKEO]I[}#9QuV[w&,UgM@Qs+I׳[p.rgF^Iv׸Z +x5YG7rmvrJzQ..KpyW"I7뮤z5?_~>6`i endstream endobj 89 0 obj <>stream +HdIIDqyضU$ +~FzLpӌF߷o}ۿ}{ +[ml)Ϛa翯?^߾_[C_~߯f-TJ[OiO?_o oz̺Cѣi)c{.mccڛ~aejql{+gv {m.z{oA l/YZ:-?_ZaI ,֔W_^XNugi{.YyaMLf#+y$ЌƔ]@Ѯ][4-X59V/RXޫ{Ϭe +q{m D̀x7BT<آjႭw +'iU'E{q^uH6?nΈ7eE9x@Q _08w$CپEޭƋ`|ē80XYڧbf&\JN¦CROA@7>W V?`}grfXlaWk"b`SiՐ!V-l0[]JeV:>oܥvɉ1.$no$@+Lljے̍70IēR~p`"3"("XXq^+1S/D:о)8`oQ,a,*;r%qG;F.{ġC:,I +K0lIxY&a CUD-Vh-xbB` "gI&rfáK>ޠl;\ Jv-Z&K + vTVw7%: 'w!ʞ00S[ˑ0HJlҬtAէ˞/+7D_RQT=UF٪Rɶ~p?&قFE'y¹S\A P +4ueK 81YljiXŵsXL'Ĺ +hDq6 ',Xot Hp(u +$*k0'ho~K[,<]ydXf\ȫ.!!GUU"DS0# ++V=yfH֙flEUwȤ7OÛX/rz$U$ULa`NWY3{]rS:۔d +ʈyDSv֙%QLo[oWAU%B!@gDůK)7Dq;}{vN@X;w5'u07eA܌mZv/Za5T8O*aZuЖSmڞ#z}NY +T'c]{9ZdO0ʥ.TbJۍpGXs +7KPeK?p|='000Պeys:Q[Y.\"危!'SdȚ KP]L[m۩%H]IwWF}~CEk/cՅOU/8+x}POP ֗ tXNv$D+ޝ|˒S)]ԇo6k͛IL?Rf^LcPCkĎԽAXi_Ed05geH5H],&ҵҽ$t/y +U$OMrT'/39GH6DpX3fÊkg`ZHnAZҝ`nSt[5k:K!XJ(s߾ ˦F154]@7Z{~Jʳq#9a+7Iwy9FgtG^Ab"vel]F(i6,N~s4+FjEg)d7v[T.OƪHRFGҲLЫ2yV[еz%TrsbAC5'!9Ijlh W˨c'FbUjr "F +~HR)%5WhlꀲgU6pNB?4;ٽeIs':,i7F`#ZӢ* FƻK)FD u-}c܆_PdY~t`NiVt >gԣk [Y>5 m济G2C +_ ] Txtvxj3z//!G1}1Ø5ޥ( `*f0N`a2\S,UIF6I}fqNb`w iFf1LiyفFA؝_*x|$S7a1*vdYz:w Ϊ&!S5F +ScFLi\ݳl~Vb|:.}fPi1Ț;J7YR(Wҿ +2B YTVZ8_qmj*f3 a(_`R}j WjU󞶫؀}neU`h():[E@.9DȊ4*KVվeтc|X3tJ>B%Wia*2@F8/^bkZoџxVljIhPY'n:ֵъ0x_r7^X`;aeYL˫ XO$Lu{݂ɥ#+3 +_wHh.H Io5`,;'s2D$xPjԖDT3pWUEToV +Cp8\ + 'U la^.eʆ1oYl=q.gzq*!YMd^\^d:4~60 I] Ǡ8dm᥊JPBw˂G4rTi>2F<.VD,<5L*l=$9\Sh=kaa11[zuőrg/z*lC5/gKy#^xa0$nu+ + 4r&-󽢜Jy6UZ_}A"'uy=d1)@Gpa4:4V]yj@㼰YR~Jl[^e d^W t^1zb2fL5*C~B5a1ٸ>#}`heuX,3JB>N3Q\a^3ݜ/6&R#ڌ` +%I#, l8GpIBq$ 7Z㍂Ԅ` ldQǕ[%تu2,ͬѹjLEuSF UɵګHU4AH(Zd(ɪT}h +|Ѳz\VGҧM'Nފ]龀݁խnJS`Q[uM6C8ʩٹJ_ D =]R <6]7E6э}JPâ8As9ԘmfsН '=emLYᶲP[N&S˄NPnBj4T#x}eW +vɥˋH7pp*z^m}Mt~wc\֜8iLv̆fU P&NK_(&M/іoDu<7O񐩗Є̔a$QPd(< O} G?L^1DbS 't[K7,30Hl9vHDu`? vXW 3gBK-E7˜uZ)f s5iAGc!NSqâ$!t$k;瞁~1(1\!H@sBw{ke7+oSYclZIӸ{FLxϱ;gffjqCৎb1.xt8F\[N`Ϭ]Z^Z)%`b6vm=t3M(la6I +_1/,)F)\ncyȝ+>P~&Tt4KY*9ij`7^o7op!5v +\uZ 5ZP7^ʧG^KPsdtϕc:} #4ݡ?_| gAq XDGK@. |~n/`UIL!yF U(${Ѓm{j2 V@ædإO{Ѹ{Vm]OWF/,O 2ȷԲ+8߸JW r6i^afy_kӄVR@ϳX|Xq홶a)i.W@ao6-c\7"L}^`JzbHYI䌘Lp g~PglI/h{յih[ڒ$١c^/*!31D%T !3iITĭ $L`P6t& 2`, |V]eX_GUcb&F;#|s_N x\9B0[vVS[yʵiɩ1$N5z;a(ݤ ECpN^;ۡ# ir5h20(4j&m+Ww2ra6llOMyi }*%r_V$ k# q_ ^Ơq|5f$ >'~90ӢY,Hp)G +x5Oga>~؜lĢ m ;XۍV g KޥODC.t I_8>hSALd9z0O^:{u)ܼYJCc%{ )?gSNm=Mru3QgM.ws Z~P Cxf%$a!NJ/`BR߃vndCdo[| _v=2=&A`Xç(팄Q9غb>MƑ}4Yh> {9(XfGfKJ(\վE +9@AELW?RmgCSm G0IEK+Ot|6FIgTϮ +4=ؙ^FK^|yz|n_`|GcށzTgޫRב6*M©"~]/:~V戼Vhj_ fA=i WBtrEcET)a$)]dh9L&V |Lb9h -|uΒ_S_d`*&Seb{Ū_+A/%,*Bu>5 dP)־*vv7d/@53-=ll(u㻱2[|w̭7)+ +w 1 6IP@3C W䁘lyWz^.省H9ۈ +u$wAUr̽%m=[ke<_q~'.ltpν}%C;Y4DSuc + 8 >>{[pzi؜MAD>}u(Uf}7NBM5,G> {qY3HiG[#3考\#)Z~@@,{`|^=ֳ'0O}UŲEyL-f4:Zor-w _&S![{|I Oi}dml[@|\K>cm{:[ۯ>2𐉾1?g!G3m+ *T a5KAc7FɄx}O?+{^9+䞶Y&iT !^M cbtW}He6**K X[oe*=cElG [Gk^eYLGvw-O!gcGF :Wgi.:Vԏc&:ar02ד0-4rXZPj+uz`-'WDL#mNdGvCrXC>yl8r +U2FjN̋7{?Z7ݘ7O!t  o.]YH$3*"lx@*cl:c 1[-AXņ#+/PIF8h3'^TNg<4P`cԠBuϚM³\Uu|bz AB93'dؚLb@ͱ{(N"@16hTD9La`g, =|)nR[]I2AR\c&=gBxYɔ9+ayuq TMь37Z^nQ(n}ӷ%Gi,%PsڳۄI)c(/&K7mUOZvZaph +*3gע-e_%of r-Y%XEiчo*X_&\Y4Wb]7 +WjF{ᒥ/\_E4oM>6+1 k#[.z { &vap\tU>b޲m[K[I&˽1_0f̛pju=W^]"/o|(i/x8+xot3_ +hZ+/ PcJR6B_v{,|ޗ$[]#˷R|ZRؽ)?Qљ#0C8jbZ#v`ujv.e$;Fp>stream +HdM gi:AFAAH2y7?(?h%*~?c{ +[}cl)=ײ׏4~G'z?R,jiJ>ۦ$SHzdCy|˞rxַwG^b{*'~ǰd"ϘFgU"}i/s獾=Zgh9jmMįgƷ_^gּc-u#46'תFzc/c]ynAyJbQ@-@1ec⥸I +/Y͜fanPd=(؍@j X*Nb7!keNeI-U;gI]'{'ƽI>9s}Њ3c;e.~Tb9c mF`>ɇm&L2>V,ƌMq2&3Jr2ɲCfhV8k\p7F[:GC \17rRzG+ra_ 'کqs;O=c.{|a>g+6\).9%6!sh *eT_bøx$4Lx; +92).kL"}"^&Rfǯz~ +c+ʍd"$cfGW=Ve @ۻTXr98uv֔ fЪ*j0XRDt㭸6ap (>P%Mx$Acw8C `67;7KK dTBpB+CTt}TA~ `6-mg$=WH$}LSs2ȋ,EkWiV$r'J聃DMG\|Os34H3҇ڔ*iS^Խ{% Gi%¬8%\fr4h6^P*-;#cdR\ @mU8Ů?cF*Ҫ(~bk9]+B4dSnc(n#Ȣ#:{}|֎C&5ž+*Xپ%I? 2v${U|/Js*MJ6嫒ĈƘ \=O8AG)z8ˍ*㿿HhKn9?h+ ٸfV-㔃tm,֖6#X@~,I٩-/fXl \V2=D QjvxNQYk.Ey) +d)>{7/['0Wc̹FMep1 +\.'S2dMBcj傦+&୞4.%wWip3A_TcZuS ;>l%O*]l? P}^s P<BlN`z_LO<$_KIָI~3)"H + yĩٸNluFO.G7^QB )RK%R7Xfi h .Y +E$O]#d*hw! jƘ]B+UfE: @,QdAt~KOʙn;G@jr-4wovp Mμ+g*?  tv/=75+5tRF<ʬ">stream +HdI$ Du@F[ml)Ϛi߿?2#kMHƏ[Nm-LJ~aMYWs(z1큵z̲}~Kx>g#&^Ƕ窽{fy{O_Vh,qToátW{h|R͒9g%7K}y?V "M}(_M9M%HHrl.YJ<Ђ+8ؚ芋_oWD#Rvja^8( igq*ۍ2a^q?o\v"I1nd/;ÚSۖ8%4$KDbCbc!ѕ)ޗa Lߚ~'\1!lβ=VeH"%gol:? +C^$7qcQ*tqݰ\,-HVaыzn*+à#b8I-EIe." {$v "HKPCbK>0u%QOK J|LD%q i.;G Gl!̑E\ozZ +.ܞy`81UUO|hQ +tU'RGƣ\ٕI桘4ueI#w8>LQ,0. ˳0-SzUY%Yfă<]g݅&F)`ErSmuPWq j$+tvMPtl_"`0~E߁SRIs.P8b}cnF{o!퍶^mPxB{[zrv@iExrE٥콜-.Y H)hnpbĔe /s-( oA!q7X : Ѽ Z.ȬkayZuᢾܰD՛6*Z'0LNsIPSL[]ۥ%HSI zĆ"Ʀ5Iݷ>,o蜫nxZ􄇈w[DGw%>CC27|U> Y=WVowσD|*,Mշ&CX)L;Rif^LYcP7k^GʞlzW*j#Ƭ1;+AQʞ,^8Z.}{%O9BPhTjL͹zӚWVt6 +ԛšvq7Fz2a(Tw;tj1bYU9<|ĥr6i_G4м>|fv4aa-LTi5^JvSQY-0py5,s&nRrmŚV7B5w\?U88H$רIiiפz5/,n Sj$ur\h#[1E7Jɻ +[i5_ik︣ϟ4z7MU ;Mm{냦.+*/ (Xw9sJwk* 5vFRY13M&l +w)hQ2dW#X_!pnӯe*-ޟH0xУI7ٔd( Wk݃?T^$ZH5Ct[u?4I:jYD4YIfrȤa]˱d*ETvBх*\*N+_o!k@Q疣?:Md%UB8uS'wJiI@1%-f7!A8K:K = &9̿#. I[s/ƪ|;myY$y bC\4M=*X~xZ Dl&<^m`n&vkou\J _g|-T7|ى-%0&HO04S5{n&Ih&+.Q{]7+ uL0hCc.έ&\ӌn'>l/ U`S:Xm*]ziAj 6h֑Ĥ$* +k r#4H %ItYM;Q!ݯ +$͵N~mLɈc<"R]iF&Ax؟\|"X|$T7aDQZ%ӂ3ݬJpjB? ;ZjD=͜)+*VA^ɀuba gKIϊ ڼBtqJ8 H54gQYG]=WME-H9@XS@x1>Iőʨ}Rv9ܮRDL]!NH}>|pN` *Ng?6*UUOp +rw`,˙7ʶ&Oi*2cbfYtà&џxV0R3P#>Иc^c˭+ ij+MaH +nE#+[3wXhH r?b4k0B$5EB =L'Xʄ|"]hɹ+bk wR O,9^/eʈnNlrfc TNUPȚ3*RA# 93tZlzzR9MM"=*fAB@kg՘6smv)%(Yall:.z%"cU v Xr`^Np c3e3ZMV!ֵk&Ai38-;O(J313Uhz|-Fv'=kTج0OWԮ0ؒGCʝV/ڿV\yr1`ςs8"-pҚWkGKq[Ѡs΋ +Hc]#ie1U z.#(N6jVKcCP#3 اqJ="hECхLj5c <\rv۶gn#! LY;r6oW1!JއRn/!60Qxk,= y (Cw$>"1RTA +Q&yNLI1qaf2z}(C+BXkSʊӲϰhF QV{r]! @<'[0/ 3TB sB6 (L.Cv;xk}kT>﵏b8!}q/H-Jȶg^b4]zƏU[y$b[1mQ?e$Hmw/"cc9RC/,@l^-2O21E ++CiAY/;~2KcI83eAhʚ:^ibJE$=+&ˌZ3@ +7Q1^'zLKaRc\^dZeG1B[A:{hh64]| ;NOeI|?gv}j#U&S[y|n)$*{@&XbdtO&{ 0†罘<{MFRvF-}I=?+㐖;(^nYX&:F\:+Y/^ps[_Ԇլl| 20K/u䍯 .6AW@m蕫zj[WVjq6|ɧ/dUl mT&K7Kf/͖u]Mio]+gq>T$e$ZI>j".w,OdhcJY3&MlHn0X|Gŧ>"9_߶B̘LDˠo1eUQ?'+~C6q Nte-Xu$2Ǻn*X\w(şpO4Gn5v,8l7o:&S)!y)H;A-aVW>d}Poq^e%9DA=O~ؗ2B1sQ*H_zr(œ$ާ:%/8]n1%ͫ1IJZIe#t<Z8vDn ,@`=#)+lb +j911}g>e_@\[VHD3l2+{Y%3J 3K' |<؞΢9k]/sE95 r3WrN{r%!iנPhA@Ks13z}!qc6~E*ЯE:QskenW[3}Ymtv؈}-CWix9jUn0-:g2L]FzLHW6 T6-Έ;5?E[ȁT/Z0h|PC:~]DL["DvXݺnV%V0 $=6\N6mOȀ#4%" ++bMbgATե`z{8 cV '.;5ס<ٛaR}!508qbk~fohf6$k+܈]  v~1zl牋:Un0^2^*ÚmU.$I +n($㜜`ބ"vf˕9zrQa9/#! ;S!̿0"iA)0_ȴpTո%TSBpktұH:m[SrEjnj>-7.C5% +ӱ%Ɓ$9g: YM)@7fAqlh>]omĭf+(=ɰ zyʻ4՗P5ELʟqVQ +W-3+`:E(a+6:*՚rMOUgд]3:@aiEiDqU2w|tѯgRiمށ-a bsZvX*9CLi{rp]ZvIpMr(,8ݍ#{H)sH lW\=0^ITDOI"/ode?z®zl;N[AG mNlFm֙>}z.:hݼ2l4@)\3Mس]qyeF/#"9<GM8}(,Xo:om԰7 +] <*-Ǽ'`sV9'G@oƈ +5'OVdſYH +5Kv2n.d?K$l^QpXX7a K5ܑK}S o+pOZEi?ѐTluɊ54%K9ewvd6`Z=`U=d -};9L:!DŽX߿rܯ+W-SbHy厯U(,#]lvgz=yd둆W`;ka#Whqܿ Gi.R9CEur`k`P +y9[v~`aF IV$HK4ըi e_a"PC9 S_`meMx DŽ^#x-8(* HgĊNY * 8aƖOeOAzJr&:"Fky3'gKJr{Bu[@d.ׯchp]ksl(ty_UR2ni;T)eHaBٌW(bҜT3c]J(^]s?u8/xۯ>،UHfHyt (|qfl_9Q:gG&CtCH +*B-We]!1Xv`U@%2U,1W~V1sb)ZUD][G"w9Ej%N#by tyv_>+m܆`}~>C}z%Їyѥ>W)--ot hPj %֏SQ `U%!c0_J^cl<ﳆ#<#g1y#F iwMT%q a wwvU36T5*y](>,ʓiis(u2-Ԉ){ld;69\`D#v9t3l#?II_q@DDS-nr"SR5 ͦ?i6'xvՊ/6DƯYnlg&à |LV8''~C/Ǡ~W[vT:ٗtlǂY;r^W҂XQkZ(os" N + (u_n32}ht<YtA /4Hrbk.+.L)DKb10VKRdo灲TBg}Y"He_ pX +p]#Gr+r N;Sr4_ӳ+ I7Tbs:&>*(&?(+K W n:G i$bCXe#%!Qsfà'^T]'nO yHUh1[Bk.; $}(02Yaf{X \}PC);Bd,Ȱ5[ aA7|ɜG5(% %VB-TCjJ^b{a[ pBxi42~q QI~0cg >8NQ$l+MH(I-u yIvMNڄά;J(T;͒TTꡤ=1',a֊u1kɗCD3WIk +>Pe9-J=kDe0q\R[%Y/|'鵺h;'+:)xvg ;E>rdQ:RQ!^):jW\0~dzia;pࡓ\ ޛ#KqF񅉅cIףwcITVͱdɇ|)۫Kޕ51#d%5g>&cYOto-8ݴmg Z X>sV)"lJ-}~C`+vMAs7%lp,젬=0wQ7Ai,EÁ߰lo\`|ԺqAf=+cΕ.o7Z z&#r!Uskڕdydž!cg`SS$ss +;|ٖt}2>BJx x~[y_H܂z |˕_56?{wPtfȅK0jNܬy{}")U# QNi/Gtb`NT \:)_*G訠5)ʙCTh3_f9^>t6 +ua];7F<Џ*:wSDee1AbR:=ڤ}M4`JuvvV$[VAB +IeLD=(("JĵO]f%?; GLB . (n{%Lr'n&ݠbىDFÖM**ƕZ=_ +Sg\޳Zgٺ՜PF(!b&iQ0cgUR:V>HU#xqZ*LqW%,0py׀ԃ}eӏ&PֳzlVzSYO"fn8ÇD2H(=I47?㓟mW endstream endobj 86 0 obj <>stream +HdKe DowO%ꯩӆG H؞th7R|~?c{[I#9{(nq~}b+z!϶Z-,ֶ}|駟w mMdw 9 %bL!,]C.mcH}lfXc)4f12ZU{{fR65Ę +`z/r 3Gʡ5,_deK!ٚu,N=j aH_ -הjgߚCa;`f[/5: 2#Ίz1@M:8Mxb]HaQ} 3CfT~d NsVLm Jͱ|7~z#롃9ŊTVn\0',{|je$ %}Bw&:D"_VbC~~B)1Q6n5jA i͂ s,-|_qPf&|J2Pԋ28M%(FJfW%@7`A}d]؊]Qo7"RG3G+J b_ کŹs/=S.;DҴ_87R[;$d"Tf@-\oб$!7̫ #tD]hQEĉVHr%}t2\e뛴I28~l.X[Urd#q7;>ZM2XahRrSVMYZT$㭸vat(ȱ$P!BcKf+0 ;ʽw'tE%J/%W.j+AKMOKAxd848{sR3{biZ"ϓQ*u\ m=l$ X{%[9DX@\Rb=Y#Y=ӸW׆_baqLK#U&GK&rӴ)iR~'pq>I6d[mp'fr֬ngƵ"K౹$Es*Vh*.CD5HPU#Ygr|WC*užy(XݾEI#yE &dl}/J.00* i^)}JfUY^dăյjHݵdG.j8& + f!u]{<9V]T!_ Sj=B{O8{kr4&\u)j#OPڻ!)%SfWRC5oJʯgZJCCpڢZ'QDhwz x寢NR̊/geHu?, ڿI/z~j }@BGN{=`\N2!!WA{IUTjN͹ڄNkNʹz3\`\chkZi/CFBE~}N6S Y_-m&qiMg}o4z"(VIZIdjS"^ 4y>J $5)JW_!Jmc)nU-ěGu(AzÇe+[KyU͍ m0ljw֋h 1=GVYڸRo鑠IO+?Iby9Vg5vGnAK X|=(;Qler&Y1R+/JK%3ܣ +`kRoZ&>hI1l,tDq\V9(]%sz,!ͻXR/}l ` endstream endobj 82 0 obj <>stream +HdK% D絊͖m#I?Ͻ'*4Yɫ(F0H/}Z,f?{ooO y+iumVއLJ~aMնWK*zԄZ=V>?^m/o8Ź?_eJxZe5;_/ry[ Yz,=Z{[7eo}[+k^\x־۪~:%ƓE\YP=10G tQ`Ѷqo=VX )L}_mF kG4g%gRw??>04+qg"|=tS$v|Q"Um\2G'geHR`P%}BW!:D&_Qz$NռќRnǝMNJ9 rY vf5/BoXqRf>YP>-Ȝ& }1o|%bqU PXP?p}fYg7bbjDWԛe`Qp䁰bXkցΝ|8{[n{O|z y R|ٸJĻsrSȊ= +oiMɩy Sņ't<#.4Dyz81W\IOtiRҚF$+`+`=7%Gb$8{ M޳EJ)\(MW^꓂4U)=:E#gH +LS$h]2 +@p{"wV +-(Q:^PBHs-w]: ڇ vcxi6d)%y& ^q\AWR3GbTiڸ!ϓY&u\NmRUIp\e)Lv)Wgʬwɮp.39K+_j]/.,WEU)DrFOEpH% +Gq'G .NF]8 Ok.2K퍺oąY7RGoZ[ߓ yxZĽW%)q'aQiNڶ=iB$-*^peeӛ SD%I7s-)T$'oI!y7\$ >l ayZu޸dJԛ6+\'2dMq#byB3y{ xkzjɁSռq8oNnpzAay\uBX"u ڿI׎/y~>Ѫ^o~S0nj'yj=U*âH.5֊6|ԴCՋĸH.ah[DH_"̴BU~}NE)kDVm $.}իI8M#beg8Nb%ʪFc@D%5%d| +ex\ +]}#(ytLӏ5 @&8 " m4~g%*q̅6X7;1јM OW+G+c +q # 40@E%BXW!d˻L!z +I.g2"f]_a=ߟ!ht + /G3q1LN8.ЊIc446%")"ż1igs oI܂DqȌMې_%/Jѥ> D%w 2 sjelӇ\`AAP4w+?:MtG|t3(w\ۚZDKWHJj5J@1%1pBp.(-N4 \B4kJqNW-%iK.6$iyCFBi%ZspOX+ yj EP}=쳾<L`@"5PM?zeOB|KhYK hZ\Յ˯dbR{(KAT1O6tMvqz<-RbQKg"Ӌ$*愴FȴHtvy@Qp.+1(<]WzhHna#|lV6YIVfD,3!].` +*6(QX,TJS-;ԡw!ar[ ~o`#GFi ӌKr65</dSatt?i5j 6xABh:W9IL;|Y3 ˧A[ fbVy0WUR2Y芀\rXq9F3骇OVu{3Pye{?,f^tJ>A#TDjǢ?oY#6џHVvdX(\főimxmEc|Q͂]xn;w{˚Q~n[\DdXFS0 R$G ( +ǟOaehHq!z-22f(f߯䪊ɭ BTN 31^@-eʂ'(@rvXo03Swm*yDB'c0I>^>AA$WdeaQE#(:˂G.yZsgK|e35w Xni+N +Vhg0Fn}ZQB\<[*udhPgS r.#P:Ш++[Sfy7Ȣ㈰͓.PB_g8f?LC}"GE>c?ALK6.Oy_x}vYM^3X;r6m"!FއQn< + +)ՑwF/ރ5\"hD>kF` z4 ) +AhK7Q"Q0$Ed39o [DZnXXVzBոCf^k#\3yA@(HM= !rbx%ױ{ЏhݶJ1pegbů B]Rms5}сn3?]Xt]S Arj|nA~CE}jÂpJA#Q|YN|:w!<Zwzz[Ʀ@EIʊlI1~AWAcD>QeȪVZx1M{5^3Pa5ȴiٔD +oáUecNKx AT|ۜpCHܴUЦr]BZmX8f>Kݯҹy<T(!ڲuJva>OI:mmG?#݊5<( T*?kNFec +Ԝb5}y-#z=F*ǁu]dzhs ;L'ayL++ޞ_ܢNjNUYe_Oۙ4Br}4eR6dZ +Ԣr{kÈc 2b>X9,uz*CwkHEZo5Hw][ +zy +WɅ*5^MbvE^rq0ШyJϑ"ֱSU%̱5@/@e(Mir5J_1y+Wj|M0s;ȷ 4ydRJ7yf(vpˆmK84c&:sa)tl:[Joo}}s =#"z&uTay~J0Dn |/ZpW!Kr#j=A9J,j wpH=]E !2(cT,-ך;YYr(q7z&@;y}%B=k}Rp(V51C3f'07q̳?ɌAw; ]ef5=JS7Vy70/<(uA#BXggě"mj8N㐐g}մD/{՝$[jF-H7E]*؅vNJ 9Ǻx ^lx8d(dL X':C c>H~XW}Bo.3>r |I f5uq +!NlbU;L|BHђjaFj8;a/<ѹV0yzeZT3ɸ6j4o5Pd`&'sQ( >Z=|#pwGʱll/xM4kBJ +ߐN6#_WlaO6z0dG"%pSR;EKřg`)Ber8waE܎qӨ;aID#` @c;;Θ0J5X0SGšl#!ٞ׉A]y*[PLjyLs`x/-jK5K$Zkx@gbxo5K+Jր8KtSBMm8/C0uI?_^NWXe~0p] +aI؀()_Tc^k3f tWګ_J4 +BԲ^;n4mŇQF|[0O +bT+$%iœጯgloP!C;gG(x,;?VzRSbzG$Bk>mJAZ2*c89: +1ܩFn?L9;~`˶[Xdžx *W?Uю=V`7{v[9ٷA6zT /\iM#}xh{U9NaJ)ssO +wux7iET%n`xd}P qƊ$v2,[(g B 7e)Ҭ۽x)&%Q-' 5 bZ>"wa_{)FÚD9bؼGUjR~"`6:9؋_Cj^"w;VG5Ċ|&%BW"~VjUKݝhGG8Q`&( Y?75CZ WWIN;/R`X%+XLD13$U͂*|ЮtWuDx%]}["!`,_XJ>~V~+O~1[cO2jsŒ;|_KPY Y7[( GF.2gyPꞜҎl5zl62FY0t.$;kj!qÓ)]r@el9+s= Je9/F~|oErg{V/E ZrܒҶ]-c̱=8θ+SZzLtP)TޡlD +#OXhD1sG,j^i{{`6֕p8%Ig߿!!:t,hMލ B'B@!qs/cSѦ\u>+:vmIҏA)N!l~.Amr ЊuB)QnI̭<ĽW._xXUv❾5Acgo.{Z:u;GC}W3+dM.~"=pXiRqPE5O@F\c|Ϭ1#^ WY͕t,/~V=]7Vvԫtp'pi{fƟfuF,dkk~s0u(R" 崕ʢ:pA.w$m sćD2v9t3ʑ}$%8lрM=5b.%1(]p|`h8؞pSI9aWoKkYh.L|d +\"1M]>)-G;M)Pꋮ,ױdҎRAJZ`_Vu]迬T\sǢ N ҙ,o(o.BZWTy.`A}VNdq؊3+u9 XFY(7XoB<r \/2&5W -7H| E5Bo4DtF-3 TIZ#I-4DyTzL^P.[E&cm:/i ֤$VVwF*7p90sf'wVANv@`n l~YfC*I{D V*G/f#Gx=,>CR(塄ęŞz+<0l>(P6հB wRBxeBH"A`:I[p@A>yitNnYJunAQWPg +>B*43J(gƑ>QP:; *)t ߭TBy¥@`䐢ZTG-=L9]1sV,94bB5Tsxn+mB9AS1u2 ;VIK|)y6gy$VMo>\I wƽKk%]'KґWՊ H}i!K' fKná[cVj-8"'(m%]o%eS6|O%9R}Xۧ\,yW}(m)p=fR:dAǣJ{prMTl\ɸl?M\?*0>{? JKҨMdڳۄJ{pSt[tąY\/2[/ +n뻈 +*g\+O޻q)$.:m޶fі.ڢ?IDvnΆ[=RK<0ꦗ [{r=n OT֞[RIi.Uo\D8<60(}̹fWĈS} + +{ʵkJn72l! 秜\VD%˼8 +E=W^]7>@~CISY)[ͮlGħ+>Zywm#T6r#ְw!~-H&3F=_/r;lMΌ%914i;~s9dhM}U9ÇWH.gui%RmeƷtmھ][|@FG{Υ);O TCT>h !]f"//VC<"t#s]XaaM_"c}Aq:}(l@QYpe35nRԦD0pU8>숝 JvI@D%4RaGlIr"t |阺?p;eiT N(yûeO}-N6\%Ϫ(-lf> endobj 46 0 obj <> endobj 64 0 obj [/View/Design] endobj 65 0 obj <>>> endobj 30 0 obj [/View/Design] endobj 31 0 obj <>>> endobj 81 0 obj [80 0 R] endobj 113 0 obj <> endobj xref +0 114 +0000000004 65535 f +0000000016 00000 n +0000000175 00000 n +0000015977 00000 n +0000000012 00000 f +0000016066 00000 n +0000016457 00000 n +0000016844 00000 n +0000017235 00000 n +0000017622 00000 n +0000018013 00000 n +0000713033 00000 n +0000000014 00000 f +0000018401 00000 n +0000000015 00000 f +0000000016 00000 f +0000000017 00000 f +0000000018 00000 f +0000000019 00000 f +0000000020 00000 f +0000000021 00000 f +0000000022 00000 f +0000000023 00000 f +0000000024 00000 f +0000000025 00000 f +0000000026 00000 f +0000000027 00000 f +0000000028 00000 f +0000000029 00000 f +0000000032 00000 f +0000713289 00000 n +0000713320 00000 n +0000000033 00000 f +0000000034 00000 f +0000000035 00000 f +0000000036 00000 f +0000000037 00000 f +0000000038 00000 f +0000000039 00000 f +0000000040 00000 f +0000000041 00000 f +0000000042 00000 f +0000000043 00000 f +0000000044 00000 f +0000000045 00000 f +0000000000 00000 f +0000713103 00000 n +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000713173 00000 n +0000713204 00000 n +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000024785 00000 n +0000713405 00000 n +0000702478 00000 n +0000025084 00000 n +0000021596 00000 n +0000024971 00000 n +0000700331 00000 n +0000689764 00000 n +0000687613 00000 n +0000677060 00000 n +0000674909 00000 n +0000018812 00000 n +0000021453 00000 n +0000021631 00000 n +0000022137 00000 n +0000021773 00000 n +0000021893 00000 n +0000022017 00000 n +0000024855 00000 n +0000024886 00000 n +0000025159 00000 n +0000025547 00000 n +0000027226 00000 n +0000030466 00000 n +0000096055 00000 n +0000150197 00000 n +0000215786 00000 n +0000281375 00000 n +0000346964 00000 n +0000412553 00000 n +0000478142 00000 n +0000543731 00000 n +0000609320 00000 n +0000713430 00000 n +trailer <<17C35920048B40DD89DC288B14F36207>]>> startxref 713651 %%EOF \ No newline at end of file diff --git a/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_05 Logo_Black.jpg b/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_05 Logo_Black.jpg new file mode 100644 index 0000000000..cb0b6c7959 Binary files /dev/null and b/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_05 Logo_Black.jpg differ diff --git a/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.jpg b/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.jpg new file mode 100644 index 0000000000..cbd97e78ea Binary files /dev/null and b/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.jpg differ diff --git a/microsite/static/logo_assets/pdf/Backstage_Identity_Assets_Artwork_RGB.pdf b/microsite/static/logo_assets/pdf/Backstage_Identity_Assets_Artwork_RGB.pdf new file mode 100644 index 0000000000..6af1569ec1 --- /dev/null +++ b/microsite/static/logo_assets/pdf/Backstage_Identity_Assets_Artwork_RGB.pdf @@ -0,0 +1,2922 @@ +%PDF-1.5 % +1 0 obj <>/OCGs[11 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream + + + + + application/pdf + + + Backstage Identity_Assets_Artwork_RGB + + + 2020-04-27T17:09:22+01:00 + 2020-04-27T17:09:22+01:00 + 2020-04-27T17:09:22+01:00 + Adobe Illustrator CC 23.1 (Macintosh) + + + + 256 + 108 + JPEG + /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgAbAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FXYq7FXYq7 FXYq7FXYq7FULqd99TtTNx5EkKvgCe5yUY2XF1mp8GHEk0N7xvoRDIJmklQS3ALLzD1HFkO23bLD HZ0+PU1ljwniuQuW4u+hHkmN5qNxDNMqAFYgrU9N2ry61cHiv05ARBdjqNXOEpAco1/CT9vIfFVb VoBM8XBiy1C04ksQwWlK1FSwpXBwNh10RIxo7e7vA7/Pq46lxuEieNkaQUWMgci1ada8aU3x4dlO rqYiQRfTrf3UtXVVkYLHExPNUYErUBq9RyqDt3w8CBrhI0AeYHT9baavC5KpGxckKigqSS1ffb7J rXHgWOvjLYA38PPz8urhrFsZETi4ZqAg8aqSxUVFfEdseAqNfAkCjv7tt67/ALkdkHOdirsVdirG /wAxvOcXkvyVqnmaS2a8GnohW2Vghd5ZUhQFj0XlICx8MVfM+ofmjKms22t6TqsOueYrx7W8n1qE 3dk1pE08UUmlNaSFoprernj06lvtipVe4/mL+YXmXQfMM2naUIzFBpP6TCfoq/1SSWX1ZI/TLWc0 S26UQfHIKYq4/nr5btrqG1v7aVZHszcvLbSW86l0sf0g6RxrL67IYgQkhjCs2wOKq+sfm02j6lpk Gr6Nd6WL5p7dbK4WF7ie5rbC1S2lima2YSfWSG5P8JG5WlSqp6j+c9sl1qdhYaPdTahplxHBJBJJ aIxU3sdm7mI3CyorNL+6Zlo+xrSuKqj/AJ2eX01a+0o2F215as0VtCj2jyXEy3kNh6QjWctEzT3K cPW41X4umKqOofnv5Z02Vbe/0++tr2N5lvrR/qvqQLbyCJ32nIlBY/CIuTMATToCq9KxV2KuxV2K uxV2KuxV2KuxV2KuxV2KqF7Zw3kBhlrxJqCDQgjvhjKmjUaeOWPDJCabocFnI0hb1mNOBKgcad++ +SlO3E0nZscJJviPu5IuWxtpXZ3ViXADgO4DAdAVBAOREi5c9NCRJN7+Z+61h0yyJJKHetBzag5H kaCu2++2HiLA6LH3faeu/wAN91w0+0404E16sWYtWvKta1rXvjxFl+Vx1y+0+9pdOtFNQrV2oebV HE1AG+wx4igaTGOn2nopyaTBwAhJjZacSWdgAvQD4gR9BGHja5aGNenY+8nl8dvgQvh023jSMblk pvVgDQlhUV3oT3wGRZ49HCIHl7/f+LRWRcp2KuxV2KpR5s8raP5r8vXvl/WY2l06+VVmVHMbAo6y IysvQq6Kw7bb1GKvMfy5/wCcZfLPk7zLNrM98dajCcLGzureMLCRIsiyO1XEkiMg4sFWnhXFXo2u +RPLeuX/ANf1CO6F2bf6m8lrfXtnzt+Rf0pFtZoVdeTn7QOKpW/5Pfl68lTpri3qxWyW6ultVL2p s3K26yCIc7c8Gou4xVEr+V/kn6vPDNYPdfWUliuZrq5uriaRZ0ijcPNNK8hotvGEPKqcfhpiqxvy r8lO8zyWtxIZldBzvr1vSEs6XMhtyZqws80KOWjINRiqX61+Tnli8tbkae09lfyh/q88tzeXMMJl uo7yXhAbhAnOeIOTGyMG+JWVgpCq7Rfyf8tWenJb6g019dmW4lubqO4vLf1hdSiaSKX/AEiSSaLk q/DNJJXuTU1VZ3irsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdir sVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirs VdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdiqld3UVrA00poq9vEno MIFtOfNHFEylySs6ndRXcSzVDzOq+gACgR9lZXG9fnlnCKdadZOOQCXORG3SjyIPenOVO4dirsVd irsVdirsVdirsVdiqXeYvMGleXdDvNb1ab0NOsIzLcS0LEAbABRuSxIAHjirwrzN+d/mzRLqDX70 PbQ6gLebQvL8S295Y3WmvIiTSzXkLtJFdAy9R+7B4qA9SQq+hsVdirsVdirsVdirsVdirsVdirsV dirsVdirsVdirsVdiqF1OyN7aNAG4EkEEioqMlGVFxdZpvGxmN0lel6BLFMXuqcUZWjCnqyk0J26 b5ZLJ3Os0XZcoyvJ0Nj3hPspd67FXYq7FXYq7FXYq7FXYq7FWO/mH5R/xf5M1Xy39Z+pnUYhGtzw 9TgyusgJSq1FU8cVfPv5c/8AOK2v2fme5Xze0Enl9I+KtaTnncOk0cqCnDkEPp/FyofDxxV9SYq7 FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7F XYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FX Yq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq0WUMFJAZug7mmKDIXTeKXYq7 FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqhdQ1GCy h5yGrn7EY6sclGNuLqtXDDGzz6BK4NKutQBvbuVopXANuF/ZHUHLDMDYOsx6Keo/eZDwyP010VU1 G90+VINRAeJjSO5X/jbBwg8m2OryYJCGbeJ5S/H496c5U7h2KuxV2KuxV2KuxV2KuxV2KuxV2Kux V2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KoXUr9LG2MzKWPRQOlT4nsMlGNlxdXqRhhxVaWa bYpqJa+vXWYv8KxKdkHvTpk5S4dg63SaYai8uU8V9O5u4gvdHQzWsnq2gPxQyb8amm304giXPmnL jy6QcUDxY+49EDe3c99LELgoqKAywpU7MAas3QbdfDJxFcnB1OeWaQ46rnQ/Sen6GVZjvUuxV2Ku xV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVIPN03pw21Ptl2C 8W4v0/Yr8Lf6vftlmN1Pav0x77+Pw7/d1Y3BqLwOZ4JfRddnlRTwG/SWLcp9xHhlp3dLCZgeKJ4T 3jl/nR6fd3ImfXbm/wCMMtykiVr6Vurcmp3+IAD6T9GARAbM2qnmqMpAjuiDv+PwEVPccZ4xRvSK RcaCkZPBacD1mPh/TEM8u0h3VH3chy/n+X6mYZjvUuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2K uxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVJvNOi3Wq2SR20kayRMW9KZeUclRTiSPiX/WG+SjKnC1 2mlmhQI27+Red3T3ljdCC7jkt7tB8CSSCKWg2rDc/YkX2bfsMuBecyQlCVSBEvfR+Euo9/uDZvZw tLgzpEev1pxFH/wK/FL8lxYkn+Lir+lsP1y+D0XSdGseMGoSws168UZZ5ftAhAB8FeKnbt0ykyPJ 6TT6THtkI9dDn7u7om+Rc52KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Kux V2KuxV2KuxV2KuxVLfMH6A+oH9Oej9Urt61Ptf5H7Vf9XfDG+jjarwuD97XD5sa0H/lVv6QT9G+j 9cqPS9b1vtV24ev8PLwpvkzxOu035Hj9FcXnf++ZvlbunYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXY q7FX/9k= + + + + uuid:b8085143-1ae7-d842-9317-fa53117c7911 + xmp.did:d354581b-a69f-4849-8caf-9529a577eab9 + uuid:5D20892493BFDB11914A8590D31508C8 + proof:pdf + + uuid:57813b81-bfa2-924f-b938-442e8fca58d1 + xmp.did:17c4aec8-eddf-4473-bdc7-9dbc4289c51b + uuid:5D20892493BFDB11914A8590D31508C8 + proof:pdf + + + + + saved + xmp.iid:96cc7365-26ab-46ae-a826-605707e77cc1 + 2020-04-20T16:59:06+01:00 + Adobe Illustrator CC 23.1 (Macintosh) + / + + + saved + xmp.iid:d354581b-a69f-4849-8caf-9529a577eab9 + 2020-04-27T17:09:20+01:00 + Adobe Illustrator CC 23.1 (Macintosh) + / + + + + Print + Adobe PDF library 15.00 + 1 + False + False + + 733.760931 + 160.885798 + Millimeters + + + + Cyan + Magenta + Yellow + Black + + + + + + Default Swatch Group + 0 + + + + White + RGB + PROCESS + 255 + 255 + 255 + + + Black + RGB + PROCESS + 29 + 29 + 27 + + + + + + Backstage RGB + 1 + + + + 121212 + PROCESS + 100.000000 + RGB + 18 + 18 + 18 + + + 404040 + PROCESS + 100.000000 + RGB + 64 + 64 + 64 + + + Teal 01 + PROCESS + 100.000000 + RGB + 53 + 185 + 162 + + + Teal 02 + PROCESS + 100.000000 + RGB + 125 + 242 + 224 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + endstream endobj 3 0 obj <> endobj 5 0 obj <>/Resources<>/Properties<>>>/Thumb 17 0 R/TrimBox[0.0 0.0 2079.95 456.054]/Type/Page>> endobj 6 0 obj <>/Resources<>/Properties<>>>/Thumb 19 0 R/TrimBox[0.0 0.0 337.465 428.499]/Type/Page>> endobj 7 0 obj <>/Resources<>/Properties<>>>/Thumb 21 0 R/TrimBox[0.0 0.0 2079.95 456.054]/Type/Page>> endobj 8 0 obj <>/Resources<>/Properties<>>>/Thumb 23 0 R/TrimBox[0.0 0.0 337.465 428.499]/Type/Page>> endobj 9 0 obj <>/Resources<>/Properties<>>>/Thumb 25 0 R/TrimBox[0.0 0.0 2079.95 456.054]/Type/Page>> endobj 10 0 obj <>/Resources<>/Properties<>>>/Thumb 27 0 R/TrimBox[0.0 0.0 337.465 428.499]/Type/Page>> endobj 13 0 obj <>/Resources<>/Properties<>/Shading<>>>/Thumb 30 0 R/TrimBox[0.0 0.0 982.381 1247.39]/Type/Page>> endobj 28 0 obj <>stream +Hd6y +dFpO.`%Ͱf"}ϗնJs+T-mWؼݼ=Gc)נ_=Y +Vx{ߊǴVԮ뉼_f\Oēl+m^ɧ{x"Wy/7kqs>sƺ[,[ mDyhbWbi?ne.Ǧm|{ Vae#{6ֹv@a:cC>_'B r܎ +i2lb{ʄxWʎ޶VR+#Myfڬ|yoi{pv +)6≼A]gN:57gS+g aI>"dðu3^aG5rDam9QVP;cWv_34~-dak=u2FjQBg,0ȹ"1QoA{KH̸!֍qT(6FBDB n@(k@-N: ={L1DHZH{X|!y#J@6}q~du{ n7:xE1| ְ=4j +⅔@*5Q fbJQUP\k?dqk![#5L%Aa$l~6 TDCkq, +#d@I]E2$-|"+@.c=EHZ%IFU6LazE%AC4N@0I癌ewSȸȌ6'ŨULqwF7I;p~xb4 bHZu5R5v'B عȊrἫ Fyv5EqqDJNDU "j&&0ZyO) 9 ᜨ;)Ea^]d{Yhx5!%rx47D:V'ht9ZKj,.M~k5 X CfIAS=z4m=W&U))S!oSI5ym<6o,ܑߨ:]dj#T]ijtj7{6 )fѢ +Gk$ue(-뎼/51~ $ u4Z4v#*ÚYaC +fEPHq" $7!BJ?pnUtnEwxwVB*7ZXj7L<+at~%#?W]ʹeW7Mq3ej?fNے+mD3=az1-ODqD"?](;K&*Ʌ\T-DLxz>Ei4`7M%I] +CJYe7I.䒤3RzKr%LM&Ʌ\tF&]d=%);Vn\%Io8KS֛$!K}9ˁ-%o[ȥC}qG_Էޝ}-% 1Է鞆–ȮzUբ>SCk2*n gL^=xEz*\TYٌvWEoe :GUU\EK;XE80WW**\bѶYwjEغZg׷JT .Ф:aZ>MCH%]߷o~}/߷ׯ 0c endstream endobj 30 0 obj <>stream +8;XF7fm2p,%#+$tPDK.?n' +qcGkVl>MWtq&Mo3CAMMmVnXt+Ue?Y=Z.\db72He!'j^FfN^m1.^94A3C&%*O;1+_8 +*_W"qTijkPd5MmOB7/P1D'A#ZRpYP<_gB:,AtF%9r0M3nJF_H#VQ]o:QPj?#hfTf$ +0(1ROHb=Z_Utn,L[;SL]m[Dh.n?hofX*rO<]1i7YOJGN1dX![L7pe\gko@I_AW1kC +k7X]c@sE+01%e/cWj"IPkD]%_dJ@03<"]r'@%<>,[:4M@8_?tKLcgU->Kg721JB=V +2VX0WqJCcfS4tIJjan0-afTZ]-!# +e?b$%Ur(%/Il44(p0_1eog'M<9n!np9 +p0$>aj+Z$#hLmCBVd"l22]/Y,jB#$BEhNCZL +l+SD(W\+KR1khp$8b$9IB/tCBrOQtT,"l)9kP__"@W:^=,Z052e99!>*]1/fd@o1B +]pS=5I$1"oks"WYTL2Jq@N>OT89lMjFsOC4#j7@lK'QuQCEP9P=8Q3J=K@_V(-bu& +'f1+j/*oc'cQ#aA1E@K)"g'uoTm5KZlg1>\(,M/+Wig+Ha$YYnBjPT<&#KreN1BM7_CKk!T992PngoqPqOg&Rc:? +%4IDabEgYYb\+eOTI)OqmSWe4#F(#(@pEfo@_LVoMNk7:#o-4rJQ&S-*er@s:R\43 +:hsDQ?_r=BW$"%R([<'$++-1CfkQRFr$i:4N[ZQZk^10M`(/Wd7e1sH)*F6Zq-!DUO'sp/9&%HiN1t<.Am?J7 +cPS&KWk3QK)8FEu@'tDM7=FN endstream endobj 31 0 obj [/Indexed/DeviceRGB 255 32 0 R] endobj 32 0 obj <>stream +8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0 +b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup` +E1r!/,*0[*9.aFIR2&b-C#soRZ7Dl%MLY\.?d>Mn +6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j$XKrcYp0n+Xl_nU*O( +l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~> endstream endobj 29 0 obj <> endobj 33 0 obj <> endobj 34 0 obj <> endobj 35 0 obj <> endobj 36 0 obj <> endobj 11 0 obj <> endobj 37 0 obj [/View/Design] endobj 38 0 obj <>>> endobj 16 0 obj <> endobj 15 0 obj <> endobj 39 0 obj <> endobj 40 0 obj <>stream +%!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 17.0 %%AI8_CreatorVersion: 23.1.1 %%For: (Natalie Strange) () %%Title: (Backstage Identity_Assets_Artwork_RGB.ai) %%CreationDate: 27/04/2020 17:09 %%Canvassize: 16383 %%BoundingBox: -5080 1539 4131 5351 %%HiResBoundingBox: -5079.08096277355 1539.48247256369 4130.81283900864 5350.91952314631 %%DocumentProcessColors: Cyan Magenta Yellow Black %AI5_FileFormat 13.0 %AI12_BuildNumber: 673 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%RGBProcessColor: 0.070618383586407 0.070618391036987 0.070618383586407 (121212) %%+ 0.250980406999588 0.250980406999588 0.250980406999588 (404040) %%+ 0.211763471364975 0.729411721229553 0.635294198989868 (Teal 01) %%+ 0.49064376950264 0.95199066400528 0.881853818893433 (Teal 02) %%+ 0 0 0 ([Registration]) %AI3_Cropmarks: -1429.73576009201 4908.85389023608 650.216486487336 5364.90811938603 %AI3_TemplateBox: 306.5 -396.5 306.5 -396.5 %AI3_TileBox: -792.759636802337 4857.38100481106 -9.7596368023369 5416.38100481106 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 1 %AI9_ColorModel: 1 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 1 %AI9_OpenToView: -3450 7755 0.09852450408425 1242 674 18 0 0 11 51 0 0 0 1 1 0 1 1 0 0 %AI5_OpenViewLayers: 7 %%PageOrigin:0 -792 %AI7_GridSettings: 72 8 72 8 1 0 0.800000011920929 0.800000011920929 0.800000011920929 0.899999976158142 0.899999976158142 0.899999976158142 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 41 0 obj <>stream +%%BoundingBox: -5080 1539 4131 5351 %%HiResBoundingBox: -5079.08096277355 1539.48247256369 4130.81283900864 5350.91952314631 %AI7_Thumbnail: 128 56 8 %%BeginData: 3000 Hex Bytes %0000330000660000990000CC0033000033330033660033990033CC0033FF %0066000066330066660066990066CC0066FF009900009933009966009999 %0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66 %00FF9900FFCC3300003300333300663300993300CC3300FF333300333333 %3333663333993333CC3333FF3366003366333366663366993366CC3366FF %3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99 %33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033 %6600666600996600CC6600FF6633006633336633666633996633CC6633FF %6666006666336666666666996666CC6666FF669900669933669966669999 %6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33 %66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF %9933009933339933669933999933CC9933FF996600996633996666996699 %9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33 %99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF %CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399 %CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933 %CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF %CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC %FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699 %FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33 %FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100 %000011111111220000002200000022222222440000004400000044444444 %550000005500000055555555770000007700000077777777880000008800 %000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB %DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF %00FF0000FFFFFF0000FF00FFFFFF00FFFFFF %524C45FD35FF8BAFFD2EFF5252A8FD4CFF60AF8AAFFFAFAEAFFD06FFAFFD %21FF277D7D52FFA87D7DA8FD05FFA8FD40FF8AFF8A8BAFFF8AAF8BFFAEFF %FD09AFFFAFFFFD04AFFD14FF277D7D2727FF7D7D52A87DA8A87D7D7DA8A8 %7DA852A87DFF7D7DA8A87DFD32FF848B848B60AFFFAF84AF8A8B84AFAFAF %60AF8A8B84AF8A8B84AF8A8B84AFFD12FFA8272727F827FF7D527D527D27 %527D7D27527D52527D7D52277D7D522752FD33FF668B66AFAFFF8AFFAFAF %8AFD07AF8AAFAFAF8AAFAFAF8BAFAFFD13FFF8F8F8277DFF7D7D7DFD0452 %FD057D527D7D7D52527D7D527D52FD32FFAFAF608BAFFFFFFFAEFFAFAFAF %FFAFFFFFFFAFFFAFFFAFFFAE8B8AFFAEFD14FFA827F852FFFFFF7DFD04A8 %FF7DFFFFFFA8A8A8FFA8A8A85227A8A8A8FD4BFFAFFD19FFA8FD15FFA8FD %FCFFFDFCFFFDFCFFFDFCFFFDFCFFFD59FFAFAFFD2EFFA87DFD4DFFAF8AAF %AFFD2CFF7D277D52FD4BFFAF8AAF60AFFD2BFFA8527D5227A8FD4AFF66FF %8A8B8BFD2BFF277D7D2727FD4AFFAE8B608B60AFFD2BFF27F8F82752FD4B %FF8A8B66AFFD2CFF52F8F852A8FD4CFF84AFFD2EFF7D52A8FDFCFFFDFCFF %FDFCFFFDFCFFFDFCFFFDFCFFFDE6FFAF60AFFD7DFF668B608BAFFD79FFAF %608BFFAF60678AFD77FFAF66AFFD04FFAF8B8AFD75FFAF606684FD05FF8A %66AFFD73FFAF60AFAF8B60AFFFFFAF8B8AFD73FFAF608BAFFFAF8B608B84 %8A60AFFD73FF668BFD06FF668B8AAF8BFD72FF848B608BAFFD04FF8B8A8B %84AFFD72FFAF848B668BAEFFFF8B60AF8B8B8AFD72FF848A608A8466608A %608A848B848BAFFD71FFAF608B848B848B8A8B84AF8A8B8AFD72FF846060 %8A6084608A608A848B608AFD72FFAF6084608A608A608A60AF608BFD73FF %84605F605F8460605F6084FD76FFA8605F8A6060608A60AFFD77FFAF8435 %605F605F60A8FD79FFAF5F605F60A8FD7CFFA8FD3EFFFF %%EndData endstream endobj 42 0 obj <>stream +5{ǀ(tFB: Op'80孄#, w>#4(S$BT*F%NG:s .*⋆;@҃(z7 X$2&FZp/:N]tP7F R xrfm+ 6bqR2rg9 +T3 + lbF_51`ѐEƈs4kiWBTeTm i(&?%K=#A\ iǨ1eqS$"pH8n7B\XGSGlIVDvɨQN:Y.vux똻*4 +G;cUBxlfp_vTFH1ƚ^,&n8oj(t:YAij"du.G@Wpf{c!m̈y0%eUcWI9Dg8u;Z '|ԺWn8= &j`S!3%1EdfX)!bݒ@6GpaS!+DQ 9 JH<5k2/ӷ7dy&x&z=BhX{ +.dR0: WiQ2EgS/,4&v !N,bNUS>߆Ű z^Lsb,* +BY'ѮxqYo4Cݑ6*[/꼩~*=F j%l6X9+zud@&'6Dΐ!*42$A@]fNm(S n2M3^ +fƸFtY +1Y;&#-~4yx][qZJZPݞQQt :]E6Sl +jCg2 aEQ +7$`S8*>DVHD-qWBZI֬i91B DR#6 M3LbA*1hﳃq<8\|N~ƖaFBVrN|@la +O Z2fP0*{=&P+o#cGbQXpV 5j +A<`}8o,2Ǒ/P+ +u.XH)tGA<]ci@aPElm +1UjZS+yEQa(4xH\dHfKVibF,IN!m6˝ +*/cл #чQ25t6ąYL&sfT8^tM\Y},Q6{.4' +I4\Dba-EBP nׯ.nZtc*e8Tv1v0 y?{]I$ jwg 9 +FMIH(qIcN6;=C0ҍI$#;N!7Ln +_6Muɋ~" .Jq,4jzbR>pw a;=If&2+*zj3]ipț1#ϒFgss%rKšvQr_qEzȉre\g#_tdO4zؗ3v AG&0LkTM`~\Ck7oR|%alcbH7f0&f*0.L?1s`#Ff&X Izrq:3L; +c}(R@E&7%VS/ǩMQxФ E9yˤp縲P>BT?95wqFJPы%V#)!YUN\6]~a@:E%7y 18*Sj2 !cD6P2&u'tt=#Í'1 y>j HfP5}^(r͚zEĺ9mS#P= /ha:a콥UE˩Anv{Fk%CPqvj͑HP 3bNr@'=]vQ&<SPM=?-^EYxgGacl4)0j9,;UndYet}4͐DZ#(K= ~ _\\A$+vX['t rh{2. " 4Q9 ^W)2M#*`.vvFmu#*Zjz{E߀J +bHg"@$SXQ/Q "Ko=+ MBv)~6Z:ઇH\Gw!A(6wyMzJE+ϱb|D O pOf+:`A "Dw'D@V覜%[9x.6v* >fijZp4i#}&fx!=%UN$Q'YrhbV| ʠ4ќT~AwH P9/JI$*+:Jō qw4(r!&ztbzACB5>ߥ3%g]ZU S9xU hjh'7Ц1t\b +2LW>ܚ:KqQ0u-'1D,j$H +c#}'u0Qjv0KL N6DyBftJL@Z[4] WLXvip(׃ϊBR +j$NJYܘ*ϓqsؒ2 +є 5zUz\j֌LFtA +?r1S1Vܗ.JO?0,[Li:xv̆'U>@cXȆ;e'w*a=bzӴ2%MF>;,0)!pk:U6 =a,5x+O| P=t˧{.}6`u֫jbBIi?xH +U'ީd?Zooˢ U6Xӫ֛ba@:V;$H(\h s #p|t{%T_hAlzͣoe=ZLN HThܴ=FP {|󖑜E zNVQp[?LHK +p[5:h6r9/QKħZ-tD!-"wyd2_u@F$=_ta 76Dv*+c;-鶍i d"Rіϔ? +\q^>V5w.)q CSV#n›_Vcd_x/3;4V%S&LH?k'C 6dvB'syM;߼]7o|s/)Gr'}qz{ +}V7-Ywڿ>[xr}v+:ūjgM:Dt]@TZZNU6WdE[B TMC^!SU|x0kmus\r R֤ZckNx RW+c ^Sx` +lfNvs2`+G{RD>@)sDCTW5%@ cE&9 uU@y$ZyWhiN a_gb$|i4 oy8<1jVt p CVvTkI[1\t^H \AHd2 +eEB^jBJE|뤫8J#\(Ľ-풲dzlt׽Wشz&h +~XoDh ҪYHڐS[@-ϗ r7,GzgnZ+nLF~8Eo:O +X<4\#kjT h]1^^t5bkf'-DfyHk-Brg&ˍmk)$VL|j_C5̎%Eg=X%~=~ӋV]]z$ fB'?CTRq{&0ajyӽ)VZ75 Moǰ~XϛtsAwz6woۛV>z/oOqwkqg׏.WO*,K,ayކoL`o&|àg8`QXY\%"'IK:`w-Co"J{ v"?b+F:aF#Z؝ ɥ !_{;BԘ͊k[S 'OąB ޅ:F.73 L{-R+6apVB򴶊5!.OP^Z +H%;d!j#%z-DbTƱ,g]Hol6sv` GF ( JY֎t%dkE4 &; +DM&x"k7PXlb }Z&X(ga8,WXUb2! ,ulEDn 뮠؆gSSJfY;c QEEtwZqa@Z^CON3RvQ$WN1@9aNJjTZ:Z4QӼZYTEglK);5;YY*y%dh֚Q"5.}MiR &z6n"qd=$iuhoٮа)5yshopV𔵞%M) V@*uD"fKi%@ KqZei’f=G<;&'s6eyG#6F>|KtiPA)VyXO1c-amPc 8Q?|zɎm>9;M]8u*'ts~}g_ߛ cNض'jձuv*~J_aL_zpP %m Md }SP5!Su5i q|? ? ŠL7ڏO^HG^/Zu;Y2u^NDƀjdtaA7 +naʘUC̣aL}]:Ñ/y` GՈN.6Pޱ}˨ 8tzGUg"4oy`BtGՕEr0ӷBjl(ի@lF/WP!u f %cйvPj_FQE#!&<"^P|b.Ҽ}nϠI"@_"o9"n]*4mDAmDx#ٔ=!E2$O=s W7e ۃ<;[34AP sLH cz' +1 0+z3(*u(mi +XѪѱi3 +hP8\@G%$t@Gt#H- +cKt^ h1u匧iOV\8*B>D5@nHZaiy;}dq1IRzd2Z5E2d]O9v%JF4}l_ֲ~PK]3(a!*ׅ3쓤Hn*;D[݆,A4[#0jG}O]& "rԣSci%#AgX\3C +"vyM(SWy6=i0'VȐȒ#7uC!@F$/ynJPJ%02/<,.1BBlQrL&[B<9'{ ެH<H^R,_?Pz9)wV"˖-+]Ij@}`^ଛ,E! o׺Gsޫ!0&ɛ*&4ֱo +Y3ٚH! f`նIiM'yRjdž +btz6I`J^ץc+i b>ż +)ӝai-=;bk4#ܸTrM4~o2:HJ(Y)MSN +v+cTo +6=)f.FH)lZ` t6Z֞'5Gu^}8Dl^'8L*c\p1ڊXl0ʆG=EcI$nJ ;:IҒٱ P,I *u ̔HPӑ'*v W6@pO ݒUDsY +WS *֤K8܈<]Cl*^bI`{:6blY獌P%Ţ S;Ge eʠXvU`cӍIMz1/Ee|.Qyj;|§A Xt)G.E +.E4.`h@. Mr0.Gf*-iPjOH͔t6 U|݊%({ N}@zbr݋֬K{ht&K4݋I`r/b*]*+J/]7>/1]Dau:]g2mɻNwi';{M'ar%o ?N۟""t`uq6`̠p0`N!KŘ`/F.Ao4AN /ӆ +Җ&d b9sLʎj[&őq:޻lCda:V#C:ߤEW>\&]t//^,@{1^ ё^ Qvx:EmEaAJ"yVP&܌ WfT&:tj|zKj䐡]Ʌ,g#* ZwJ 9$V(d@P!pWU4qj/-@efӘ%_Lek8(fxgb=I~. 0Vj7鍍|'`"O&K" +vMuwڝiTωɦj) + +.Q߬RՀ U S %$tL*HYL&(m9 Fѯfr};pXU+Ɨ"E/pʮ5%kue9VrYePRdkV7Hm2=K*!+1}t.FPNNw:ZA +JC zz:bd[ P)EC>i&ַ$@NS9i6I^h!5Mb%0 yCږFă +J~&U *0*{S":Ϻ(񘅃d<ΧBR'Gm`p擖7dA ϶zJn)V; w+HX)JDy,֎Z 4J xV + +׼+V=d`6*ğza% +KޜN7%a]ys,6dZb$&4d=BGc\ ŘojDf ]'WMhA+AY"c; 9+-7r)E,S6Bd+.`E eKջ{<{R.$\'#`670qCڰ@.Wm9 P>,(AYecʹfE۸͎e"BhfpLr:,>Kġf"K%J 4ᦠ[XISP7{H%fOXl b3: 0@29w&"wzchK( )M&k>@>lֱMKMyx]攮!6!H [2k +qP +?x-y ćd`r^ԡK˖WDdP20ߍ^:iM$W­Zn'- 9I!]uQ .AU4 +Pӣڈ·޴UqѩыsԠLzhVx`)Q<Ŏ#~'濺cT d sb& 1-@Qյ#T;12\׊IiTlM#hь..3ZViy$ 2&"U[ Ȧ?VՃBpTH5Z(8+Ǽ_RIQrx #];8.wZ <.xN6Š(%0#iKR[ᢱزdi*6A@p z:[NKfdjb&%A\1k.a#gkkӌ֤kvMOsK+`ge].X2u kt?D+ՙU6UzwפfBxg~06I!%:/Q/XҲwyS#kWUM#:t(JcmŖ,QHq-׼P1ց(C{\oq}My_|uY-` džid &=6lac$dž =4ad "{lD0aCd Ƒ=P>RF#{l_8 E0aCd Ƒ=tlF0acÆȞuu\qdCK(džId 6D0acÆ&=6L"{l3N#{lDذ!džId 7DX?~c'=O"{(e)Id-+'=O"{ٓMnH-?~c$IdOeįuٓMidǑ=o~c$5Id&g0v =O"{DX!Idu6D S?Ȟ\Sd'=oac$dž =6L"{lDذ!džId 6D0acë#{(TYdVγ<4 @򭟘 cd`S1&7  CA L 80 nVj2 EbPLMM: kL$`d@ .0Ni2H` l l l`2pd2 h  x*&&& +qCd@gE @e@ab2k&ol24&Nvh2,J  %N&V$AedPll2$Pa2D4dML U8$ab2M&l0X41p&dT`y +cd0@4My&I}F&zS@O%hb2(֮d0؇blcAS%:1pb2Hl2[6(L տ,jVZdAiJθ +p%4%M.4._)_"9jL_QSg@q~ޛ6c!0‘6. l>F(lMyiҬ+AN)>,:TZOHdpq|]rl>(>;l2D|4AvB2M(_8!20$wq|I`!N`l]Uߍm0l>PLփlqz Azb8{}QǨqz ֧փslN+uJAw H_lVqdAh˺wS۱nxA0d"t&+iLe5sSVfX.f2{Pvsѷ(bW.\eK-`侯b ,b66eBPMVG[=Q/)hlRM]@Eteڤ;sCZtɗ3B]\#'+MĎ"Ҥ5*+5oǔ4}Ȕ%2 )RGcJ0v\i+eXEQ,c+K (eg![`Psؔ%|lGkP\m쟦]Ͱ]dѩϔR堚]$rUڌ/ v +Hޏ("VK—W'l!@ Q/)&?@dt!|9 9Tv"+MGVG%GVR'M̠2& /uYi"HH7G +V[+T!Ǿг 4&6(Kl 6%WTf`42"ސ1u]2PۨsȪvq +ikq3J< [/R% 3J Ob'@+>h;H'a !GRI2~9Q .91l.tG:Sg:yT8~a4@v0lG0_&)kdSXE ұk%ֲ ɗ&o]F2+*3τ$AG*)9!uHbeo1}j:]r?592PkCD1GH{c ] T;`n®4,5Ae,IҲ#YO(Sԟg YͰLS־Uj%݂(7G|iѬ32MA3Q!cJ9POƔ'a>E/J +3Dvڎx`y9~c CrGط[u;tdTA EKM2(U+Afl"f;8Lq^ĮeʒES.E'ƀm]r y_ǨLOJj藒zR`rircB\b48P."&aWpw"F9=D&tPӡk}z6hh" +0$iibLHaE,*[i آm(XJJ +cf*P4m8ˇm^S]L&P]=fNc0-$+D@E~<ѫlT5UyŻB4q,jr%ڗ)JsN vP[F}*$_1E?]:[2͠2א|WI`D]#e{nʯ>=O=c31ZN  fk= gqT*.?L<K[e{LYU-+pᄀoEP2]S.77B~&[_ 'P*>r:ӫțfy{^ hd +8@bɆ7țbߌUF\*j]ypyn`gywN~rt `R}Luؚ (2ӈlZ4K;蕞KdMd%pm8I.p_y_Dښl+NDp3UF !+Iot7۽?ɚɺ{ m9ǿ9~CM;*|rW2?~t~/V~ _b}t}vo/>:bVOnyv}sUNO??_녦k]*#`TkVUӋOOoo;_^_}Ǜ'5NW`Ej<ޏz~}sۏ[#V||DgϿ7dWW67+=Aɿ_P/aQ;|_տ[z"/!w$ɶWKs)QmbY- c4=cS1D`CӰێ@>j­[K6p JE؈(DC+ \A %G7t]򿌨aUe'#u"LmC":ǚWG.-z:~9FS >jA.$[ż๬4ԠKPJ]5ѴW$p +ݡj]bu|V]@:J +96xt 95KwTc.N5ɚjWTmYVnc,OS.-" ߷솋*X']wr9;[#x%ޢm#SrXe "3x_a(]IǾ[!E61QdAr x_>=b4d[u1˘M]T{$M!diѷ[yPK'V2\pcԲ, S罍`-Bd}J|觋3-i59ͧ>e 5|px3PO2Ա}5j1BKm3&~9NMCx թQ) g?4^3ùYN%2uqqabӒghLc҅n#}G+XVMޏRjk܂ nm%"\Y %#*({0[̑٘fZ@fL{Z ڲW!V  O!w4~EQC@Zz@,=T*dԅFUf5 զO5hi蠍^lxoN7>V-NHW%*&X׀,&clpޤ5kWgS#lod1V/쁰R8,_vq!% +D4Ԭb%~6xhHұ}sf]! +zX^1S!Z^ eF2Zf"C PI=ҡ9Z;.4c5D}IfF<H-bb:R +%;nfGB4|yX |bYyFcV6%  MIL ,HWQ?fBP!D7!{A Uw:4 \JoA.sbH j?9Z(װQ!hUjyLmנ ^LD(=!"G!VG<}zqúJ+M@Ƀ)nYZ{B{QKskv b{gdc@:Qo?N{ڀ"jQAc#I`lJ(8q8G 6Ʉٝp[խ$D8;3;kVJ]B I +D󏴙ViW@ MڬJEr9Q"*ABq(E2LX  TX1;U+E56 d OS fHnMY4xb/JBJߐ|OgɫuXw3VQ$h"UpA섔D ]*C(PR"ACb#>2@#rO(ĦNrGKRnT5B^H(47*VCbCwE1'9HP6Ip@dtnW $c&RqBNS6/*4)QnQA"\'-@E' +e{ +J.[ +ñ2CRӉ'j0"5I4$aL LNI'mI. HSNoDRL$c`w$Aƈl<=J+/Ql cbBL6 ˘M|2 eWƉQ1KF$SΩjGS+*m#KЈ#)ʵMXO@[8-跞ԩG1CY ɒ,SE$ƲfKO9jyoNC$f*&'A&L0GlA +{Ej̒$1޼üx 4$KLrZZjN h|b$40#*j*isVi 8Vo1 HKRl(MA +t'd$szhڐd00Q5YW AB9B6emUʴB3#װby, +$*@ S xWԺ# mC(8GS$%E ,b*a2%9iI*7cC$!ҴOYVM]Sh6duL *jLhQ!`уFc2C%sC3E:Hx-!j5_p$Ce5=:j2Z^IaWlp*Ё 1P|U +?5<#A@SDOqh-S#R ñZMɊ=x5~DKT`DMeULƣL 0 +zT8+Y$J_v'A1iAM+yT,TI@_6z34RrEHӲSO'RMP++4e+Jd$9-F)f,C;fI*ɪ)b &a{rhcs'DKX6 Gs@GI:ZW5VVH\Ҋ>8Su&By$Zbf6D¤5ISr% + GL92|CBkx]Yj?V˨q;VP4!)Do+ +ԴMNA!ShV#P!f:>FCRJ^pĜDL$ DT74b^i ?{% +>d)SIC +&`GbRR&!U![L<|i:z^UA*TaTn{S̩Ijx!"$9TQ!cXCD+1ɍBW"99<BRud 0$^M:$p*0] b"HuENm$_2abEI]JQ'i-=bR-+jt2,ѻc4* qeh+Zj$L`Up:uѲQÿBbƨr D ScjVPwKD05јDDx dU9K'G2)Rh,%'%9)BGCچd_)Ktg^(jtD H!$U)J(4 !9*.HɥZ:HP 1+!M$@kVPHAe": V ńo`Bq9hv2D6[ + +j&5=²惊 z䬢&lx?,ku;j`4JT#U v,#fRm‹HThtiR2jDDk= 0PHh7 +w DN"=jE.X;ANbARisTX"UA`II &z%&P 6nK(si?[d"uAP$o4$ +&@ yD%Z©dt@ Cw 1BƟ7G51LZ0OtOgbRm8i`6P.MjR̀ȘI:()ti'D\} IԍQUϢt?3u@F@-fC`F@$BT_KNVM4W95q?I(׃ 7 ^!E +Z'ђZ6<^M:I7IO(h]z$s?wPD@~!$G%jyn/ +̈́%~N"eEtDMD)f z"WcTDwMRƍ aH +,. ?ސh:+͊U U 9WM,"Hh:iXr #aDC ZdFZ,C8B4 L'3\-FoIc\ᑻ+jEiO:tP PZDQS/4(9D\'!J51OPe ^IUVdLF(d5CUJx6k<:o?$)9(j{^ + bej +%?A5pq4Xë|H7xg+XM̖Ib~ҥL- +-\:viC/tr R(SD@3yRE*#ɂ2Gv ,"2rJCze(Ky|k>LmN-8h{`[3Ew}ƑcZw @y4vX)iP™{oy'Ysov0X>&>雋=|Tt[3Ѡ<-wʟ~_ej5?^YU[``2ƃW`,~zam{ rkb{ڷ)£}UNt?GՆCk@s2JQWr+ Т(HoTw knOkS_Nۉh0Nqʂ! ƴ@ +2A{8$_ `-Ӈݪ/Ookh|C! ʝJA7ռ &7N]DPLJ%$d ACd6 +}dzg>$;u1#NxD 2Y4ȟԵ*-!z (&$n&xxr{2Aj@S5~>Сa]? hG.ȃ5|Ip xPs9gA@U9f\p(AB.eb0fǸds]aܠr܉A[f$ V5X^# #($+;?ض+0Ja5"N/@(NU X: SARs8nG;&sp N zg!Lu!6.OGz.}NתMy miAWtf,9T󛚗ie ];A€й{8F$@-F4 xMYZ3)Ld}9 ~)0D5m;-, N@ w:9['0* ru|4Ev!@IWH*=GV$3 򼀑yJ`*B+egD#T󷖍4b[2|S-FhX/JԤ؂6;$$?(4roY 3ҙu%Lͣtzj+f.H"y41)+@n`K5?3xZ|/Jn -Ԭ+\4FB梋S5E],,]j1F) ?_a&UX,W`sn/x(׺aitg/ ^  +LK|0(ԮWtm?h&1TWJS3U/+?-7`Pm-klt\M҂ZTZhP>ɨQ+r;_IG(\T1`RqO0fQEQ_ ٌ,^n:SG>Fr~V(cjwm ysАqF@%X9xI UoցZ{AeYf{&:vkiNs~ve_֭mYjgwXCm'T'_7]7BFg:zI/|~3 ̑b 瓴bfo!ǿwQ7kD%qdVRba֍S&),l1L-qTzt9oK 㯣-hMV%oL7K"_" o&(]g"X$ "A1?rϨ[ARbm5˟e_Hu֠hΩ).2/b|V\K P"+<+q2qނe! ~Ǵ`55KFi)Ћ71\ 1$WT & ?s:QmVvJ?DHgˬpw6L3J2ɜߕnwgRcCH&9-)kr<l!%|hyA> &$7o$@TL~K d$b0wA ~iԫLf¢mNyY38l-qϒCGmd Gዉ]7۽N\6ut(\@AR;5#,GrQ XILrˢ,Xk^,ݣNh&Kw."O/Q5:}r SaS Av38NQIrr!pDВ`˒IXT)YȢ(,ËcRNw@Ma'&%DE h2Xže`x. wSJ2IQHҲI,&I"1BLD3V$ó25d"+07 J"9XHyyQPH~ ,68~)2'% k =c` ,I:/H2?Yp> +#XeUH_8Kr0XVJb絑9t7.2N4OИI5Q3Z} 1aI̒e+Z(`~Z `a ?!+X7vS9C#|Xr{|9l|E@4+n(z)B|[a<,cY7ca[;Fnuu T~2jW~bZUV_6Z-3kDV;?)Hk.@3X~CSivjTpd[okt;nS6խ~k~֦\ϹzsŘ ĝ'?|ulc1.: :<Q_B/:sq{6S_8_| (LwM +)\ +:ڣfu[~vϿǒQDTdrҲ yea8h2J2Nܬ5k$ĕ ՁAfLFC5UCN~TK?Ko x r_=v}X4<9\S ~X-KdU׮P]DVʭzoٿ-u{/>ԮC9WBg~̠J\Pdj8C FFI9BE79y6:A†gZϴ9T4ݘnT$/j#ʵYD/k< Gn</0; 5xuYexƨ] vjÿ:3|V`ޮaun?j *AOp>E?}2V~#s+W $Jjώvgr&V 4< ]\쎆f[zuGc3l`e"X>ΘNw0j4US[g|z=> >9;(K`k}*o +@Q|A=@i &qUԉBY"]3W:~o=N@'xUf"l@cY(r]4;دT,% ^oOċ $9 (' [#Ʌ$=(ԇq]$cּ6:I8lRo%,#f9AN~s2ז̲% +ǙfJ<'&rz|L$⌠r=p3?Sj(gOIY;SR18q=ZW9d1. +䴻2xiZY-otݡydVLr'e h-P8`E7npf$ַW;?uub}mfCKhɺ7A$t{XD5VrA=j mc> c~Z VM%}w.Kv]Z^h{a0_kΡ6yקyro#"m&1MxaEx75IwhM4GJFB+9wRN%3\2_jo +xa}:'>$WtSCؑjӸ}:lCUUNU5=Yp `/@xod }{y%&$'u)fU(ak=ux[72Y!/acٌ9j 2[{V v.;#'.W#zGXL4fCM&[GJAIf5ƫƅҶw}.|N&z |M7d +r lhk^rdЩ<-Wx+4+xb]Scz#T?@.3)MKrg˶6]R}QgdaS0yMrB"N&7{jkwTpmJm4YmEJd=2._#0l+?~vN0ԡ^;N/ n0_yZ1^h$xhjr˼t_S츩]p̺S e}]Lx2Ov;]wL`O/h]}^\L 1 FNKkLWyعu›{V2_S}.6, 3'0ʎ.Fn3<*DCRZKg﫹rxW~=YU@BbER>˧t)b4{3l +V +Q龳Dh<<6:3>^yu.#<`_0صWZuR.(>^}]R.ʡ4-usw ~򙋆oC.Wϳ8g˟05,|lH!mKF.߸$K_qBjB9ܗCQ-v8}Iك[X ;;HI!-dKe9߾^V2rT<%;헫% _\n 6A*>a/~[ي0\NmKሌB:c#WI^aGegUm|gd-װaiSxNi}o*Ɣ1#aQ8g0Q#_`<JO>riuFy▖ڥ?1{_1QymzzJV^)xA|d8}ݓt;꯿;0u`n~Tʹ˻tq+>}dZF%``N>U~whi*i1G8\RuFJ>2l-Gj8G + d +L@- +0W&ӖYm_&!͞Nt5@& +Jv61u:v&%/^%GJ<6HW[4Pqbo o۸dD4ex6,\ľ X%؊gu(}4Gsgrv-dG z?3eϼWy_ KYn`jwBLUNwO/[D贩8v*-=ol9Bt2A-s|l3bEF㶩`'먿|7+[ ikl[ޢZ.q뺂]x;δYF>)*VN&pһڃ1UU/DmX-6c3ƴ T w=28 + b%ݝ+GvOv_meuԉ˳[< tW{T`NzVqp^F^mf=fz93}dQa7o!ADؤ.W@hy Œ OJuX"{u暹N" hL&ELWdܗA3u_$ J艹T M*q;WFbuq-$N HR!c:/R#=Szъڌ* Ö(K qv :r +锂Yc&Ic(}qxjD7&@9YH~ڀ\S?պ +^JSri'Vt[99t#N,iܗGqo!S-I;I{7٪*?&! }NtA~>{Ea]0fF-UZ*i+6J^jqop]xhUţX=ťGϻ՞r|;-MKiI/jnEU)iu/!-!{g;髏Ŏx(FvsIqigs~åchφ&N4^JZ+ w|kݝbo5fh VK[\y"ӊ}a0&keTx5r5ruנ^ +)fXN˱R#Z{Iw*ݣ#ӠW O(6`knoڇ0ڬo@ J@nY5=k1;g=dvJf +q:Lw&Vk^bL5}~|PƫgRP.urH +Jo܀{EV +y+c#ʂH0 F2"fAGRp(Mp-_- G$5Yxv`6鯥鏈Ɔ|z/*ͺnn.K\k5 }V}wNr9__M)Mn@V񳐾>^g/@ sǙJmTX:oϗP~nW= Io:%sheLh(elsgj+,kN +J}(w}X&̰fi E'Ǭ:Y+u9?c'^ZodzƷ$1R L>O.{_ WWO;0,ˑ<2)7nQܖJ4@PWԐ,J#Y skD ~5y,^;)s/8g+Ze?8A7G'ZצI٫Z0[3  v[wW:P}k$qG@6S AVA3{zV\<<<{֎6Jj<5y97Wc 5X8Sb^:ќ/-4Wc:K?Zg0o5m{d&$]y7yiċVؖ{3P}έ9 v_|[> >K#n lҝ v4dL+@5sgWbg[ɰޛQ1w9j%:~#zt:6y=6BB`zx d_?}@fb?V5]4g;^`֚=#M3kƬ~Xa{MjFBɡi +wnVw="i7Oд2!bh'1>sh^usDڶ1T}|z6ѱ[+i&0` g/tl?:xs;/xDE? -ΜFrAD^_(02 `Vn265^l17w7KA]Kea.sd4 =C݋:i/ucX# : ro0Hlء7p\uNnݨ ʃ}?v\qi8J͎:C35LT斠1hFh5P`dpUYUD1 ZNɤ8o/Tz~W(V9a 0Agu~2MslmB`0z/#">0\]P/Y<#[5Gk{\7j[+"c/R2~ Xȍ仴[I0~c_s/.ȋm~4>7zasÞؽ(%֮N(z6MxmCP9C e€ryY|ۏ$oz6v+QX;wBQcTMVQGW.7+Dt[.]P?QV0jdoJ0!2 {~2U|nta;з<+Vɾ㉁\/Yl9KAkr58I lTl΃";z> Kֺ1h l ;X< lDkyAҥ«/Z'Nke +ˠJu)|]Hb}bhg=z+9Zɠ^}}+n(8,tݾ}AwuWta[ifC+!nB0 + J1V4;#K"t=fO8_Aquؕ~\ࠌm~?]j{ :H~mf2nR^ +C&Z'upr}pɶo6x;FV]=XjK̾ԷoyTҽ{J/]ջFuM4Xu}{V?6Kʁ̀XQll45]MRw]}mjsx~V\~ /zoo̥v bޫ0#NػwXک\}Z=|^w}OVo%bry<`ښ"۶6߱ʃ{q=mU2J-|S[LwpF1nf6;R@*?q|Vm殲tuUF|%nm"ss,2@Mt"rg ֥UcGo9ܭD:-xBnK1,t\QyrTxxtkk%ñ +נ#2צAkƠDmE__ie4@WrePA5X:(x ҿ렁0 ^Jd +cPmd>$\X,16sqG)~ڔ`7`Ѻ*شU+uL9JD;4o6)Fk>ć@-_FI@Sea}k! +?;[׷ +QxO`ytƮg +]Te#S*>LyGpbܐ2ʑ78y[IJzP7|7P852 +`l7?*؎3b!Wv*=qiK/$PWlj6l2s1ӪUuhFN ' +ܽ 14{t&ws٦[^Y; svøsFwcW&sgΜ"p7򬏟r>u+K#Ͻ\f7oMitI1@#c7 5 ө8n_S2͋eXt^2p[docRԊ0[®ɇh4iiʖi09^zr:N;k0^) \y:?"L]Ӷ.{ +d~vžWh]QL뎏yՙL;/W[ffnn1+Er^=])ýұs 7BIv>t2291`yXZ|貥:{-O4#8 6x^&o[ųMDL&b-gɄcoՑ7\X饤H^ɠ "Ũ`Cq$.[J-Ƚ l/84_}}}k>@t0ٞa [!^-rC62( /~7V^]%<`G|( ?"Y6-GQl]]g!%MxdmUwQ'daif#K8kе$TFVg} M <%5oݏUn68gH{ѵI&O|O:NDkD،X%,qS] C8“vq\\Jcbv6QEsd0y)ڍA4u dyNSY.9Q>3=E]~hw۠LDINҀ v4hա"l^Y-Ay/`5N|JfY8CL-ӑY64 ]|Kti&>fsjr0'ڻB0.v>륻Omlj a}(]X!<#ߛt5[㻿k2.\T ;_qr1ܵMZ\ G7ٹAwE^] y!0?ssC /|QBܑ8ǎJ2X-tzw[{:Cg",$1i:w-K;s9η#פ|bR92=?Kv5 uH3\6o_XŸS2 G"f#4y1t:h)c#тyGY9b#,тsyGyD NCAGCN;nZ"tb::"M8${D]mwxH )9ZzsKl:ΰ9`k}Yt[g6f]: o'Qw|^ya[M>jzb)D>N\yM)d2UМ/Z=P rc ӛ݌$3ز>ݬ &Þk1nn6V-iR~}`yOgj:I ۢ,:`76gR?U.LSVxwʩBLI۩Ϩɺ=.[Mg rf&oofg*]Q`&I6Q7?E_eR˲h-J6&r +t߿>jEqI11^`N!7n>"Sl2:'&_AfebVGcC$?Yv;fGn/g¥%+Gv{ew}C5 oeϭ ]LJg*71&8 qldx1*EC{0gWdA)Ocꕇ5s>@nq;&ȍQ#7A;{*}Ngq{ KCJ˔,0)nǔy2wJ铃⼦diH",2G"3H+>,2߄𜕭<4E:xxwL6Ef,Zp~ ,2n,2v#9s0DdL羇:|l~~e~fc2x$ɀu,m+2Oڌfd֥)\'M4;J|yՙBG="'A=TNItջE|2:-1i1p8"|?6K<E{psYL塺x8Zy}9du = }x8{QxYgj9[<]sH#-'Dɖ~|"o|"'7?%G~6'~đB1"<<|'S4 s7;jٸxP8g:P8ѳP8}Yx(vK+ļu)9B9H0/ s*P8gOK04sdVy,u®,> ؙ{f)e$^.|^xeRQ&7]ؙfu`ŪqN{@J\7[n6kwO%k@ }達 =X:fl68"=[)'k,SjIjғ{ܙ|uaF/kvl|6 lwB];+حp)2^vimwA14=kkc`ك݄Ɖ^ +vcAnv8zv v{:k{GuK%AWOꍚ۠uz|Ϟ]on^Z#ȮUWMR|gmvԪ`i)4M*6w}k :#\jsԏ^uBD~ y[zg3m4\\40sF9eh:XY#ݣlΞDYYcsE9u5!C t3r)it^h:X:h:X:=SD9ڵ +싋s]b]h4pc]L4S,K9ƧXvfsNEG9ퟃ7Ԝt&)M\l4Mg~'h 6u4g¢cMh:ȃU{]D4݄EM/s!/*nR$bb\ͯ R_lMUƋTks |p-nlfW76!,^tNÍd@ 1 (` PpzvmJ~Ir֋IMUvkJΎNe]H&r1F l&6ղ +]ΔʧH>K;rǾb|sHA?(t7^z~xZ|hQD]Na ]ƾM(tw&a|c8tyH?3ͱB9O~camALIi&Hi۝̷H 6ǎVdFkiu|1 bZ}k&.'ɺ S+}_\Ø""=vTnEJ?ALk=^y?UN j YmCZb1ٸ{V1_H7UGJwyaqۂb\\SSM/TS{P)/1c|n>'Ǹ”|G={Ÿ ܈yjqwSWYtҘT^ZפM :1X?[/5SfS%C'`_J p#Ӽ5?O&Z#&q,eLnWS~hYf(y:sPcV+raz0\SYoi9&7VYffO6ّz|Vǵ[O/~Z۽w.leټ#Rs`-VO-S2꫷զS;e(PNs+PI%'q^+N ƎUflR5{tGg62&_rNM3Ng…Cb3F-@W w(@1i3N7Xgi. +o Bl(@tc5m@]G) +eJc1J:U_Ey Y~IQISƌx ( …+G^dQ>_+)g*DI ^oPG 峮Ps3-mM4k]?~3rmrħ|f7Q3D\YoR$ׂ U uf]\2~3Tq 'Y9#ze5ߑgK'.~f2PyVug&i- }~޽u/T':k]?QT\2֪~]S0z]q ]O9y;\"Rgo9-uH$8ko~Aֽ{]oB99x|0۶>m]?oI3,I-~ˇ8$N:y7[fg][tNp|~4){øtǪшR^uE-u͝I7g]?q ]oz|3Eڨx]كMULUoܔllں~]6.6s]?oG'Mi~b]VMWQ \NUfSy T{w@ʑg]ߘ<;De |%]ϼ8ѵ.ӹ7s\X@, %8{|J5hFE]o{[آ43nov{8xjRX8dkIEgMdoܠrx:[amTӽ8էG֋ryk sy^es_pR>X==J|?<R2.T}8%O{fv}*5Jmr +Vk[ߏt~ޤp?Bwgk+|(?}ogvݔoCTRL!sa +#(^J?˯be<]葖D.Ο(KZW?xb9eVK|ɛ^PK@b=;^ONE+!׵Hy~|X[UN-jFifO8CI RY׈ѵ=]dMAsմ4 kNpQ {,IK^<֖RQ*1m]u@??Z|f05}U]F_IChNLbAo!ĉd|M1@DOiD9MBa׀=A6}XHܩ 098o-ml-,  f'XލQc,(n}vn +;y,jNa'_ s?au-mn>#v5mLK^M:^f.ކ18q}>UY [dSj8W06ۖncKaw5ï $樸zUs,O ~eİx_ +f>ym݇gP,zuL /^(ֹ|mT +UjAnD #iG6*4j)D j5t9&LKw% '%w ;R(om]-+=, 6f=.J%gl&qBNh:!ï{kJu/:ol{YKU%i޼~o[H Ńa=Ap)Tz_~suK_wo^JrxF,~oNVr'-d,Q>o1x<7m9n{~ZmtM~ =e;}}wwOo߽;~9+?OS ~~O8?Qۍ3m?{JG|v\ ?_?N[^ go|{;_ӽQ~ſ)?W79a#Ց1nWh3_Hn8][UN"7{E?}'ḾOk/_t7V+<?:=1ӍL^f})[Qsw o7ۤ!/Y/}>~kYɳ\{"W޾~,/UCY'[R/> ;onY{~dd^>o']o9M&(`&7|;WE7nd>c{~gHws^,RϸݍsE^vc;㬄M.e3s|7OBJ<`}˅vO)S{{?9l:ggLF3g!ۮO?c {kuzwx,o2Eç)ϬJ<ڡ|J8QvGі?tonɏv;%ű = >$O\vtqbn-I7^\3n󤊪79O-S:"lI̱'?'ßv[pC &:OJc/yo/M79{}T!\˯(};?=>ukO_Y~S/尼i }rq}jWO>WEC׏ʌ=x-hOt M#nL!O_S +1N֟~o_g=֫o:w_>|?~x֫y>|ϯ^$cgG\B=/YewǓr]?ݲo^3`?s{Y}4?)s?=V!֝ZԔ ?W\wG>nYs-jDT\מ5JR\ryke͵8ߗ5ע7ʚkQs#/˚kQSZԔ5Wgeib/5Wgm*k5NYsuV,9xpW~ ףGo|=b8{[w?;VFc^ot,*eZ^5pjiXjoR*9ULǺޫ8JߔLO]tO~#jom&po D[^z5s8h/9?[{뻛;;r;HUuh*,_TCi]{P[U;ZPv]uZX֩,G%Izzj?}TaQٲeC ϶1aIcTC%c]<7zn<vCST3 ?=w?MR3Lg{N`?c/ /jM3SqWWk{{l~OO pnR/ G/ٗG}ƽ3٨al{OK"~`>oܿo%g+k/!!׾y/\+G/7>}{_=k1R eAj*Gsv׿12͐AZW>wDݖл~CƢ0}|mPʗ?,̻GV:}YXblמzfFw?p_zoë񓯮G<1g#?^h?}R|v[ۼ"~R͜I>x㽾駏?4Hڍ/>+&~[+g??r>6|W_aX_FUcOo.Onjo8?b)~>x߾z|wݿ//_yT &\z?kTZ/+? [ӷ:+g*w)}bS%9'-vAjP1,Vŭ7SlI+^5`) (dn[ꊥ>ix(fje_n4beu2w _V~w_m+מzJ?y?w_s:ӅE/]Í].^}Szw_|?~7^ '~'u]Ǿ<ȏxxtrݑiWOIˡ!S)Fި w) rFGoG{T?4 cʯ3MZQ8z}Գ}J0{7Kx> 扁֐y&}ԪGS1 N軱ʽ;OCp=mZE*;%zЗDѾf5RQ> +:fMеf)I;KEОZ8+hic$nj!ҏA&="fUSuF;l _Ym+͈Q`y2ELNc#FQa*v~hEq\wN.ԐԸwgO#-գg{;rKWlL?owB@'Y iWؕ9IXˬ<`g Z5YB^#+irN_7|)]"V[n 'WҦJ@nүMtia>hμeVR:0rԶQNSe0Fܶ4-s=Oeߒ(+eZYWd-lGz1 #"tFyvVі.s +-J8oHܹVi]C+gr*/_m-'${9#$Y$"z #E'4G.$6}>;ٵOZ>iW㐦TԂs\"Ly-8R/"\'-82,2RI1i:\2>[hKdYgNoCCrL(؄7%0-9wy=:DBw$iY|apʦ 1Ϥ- l~ Ƀ [t3DA&yŸ>LHe3H^-`Ѽ]AX" +$M<ɸL7W(9:yoߟqh,T ܦb1Fi4tn>gS%;C+&CL5O8)B3ЪvBq8rɳk>R1&rqN5rrMɼ;Cل6Z$kGI(ZY%E -ؘ{m2JO&NB!`6~ .M㲣[2COqiWN֗^ѓ&HJ/ތ+Hú~%lho0(ɑ4q\#會+UiN皇vTYɶZVա&.wG1b)h8${K`<'&,3z9Bu-_9̭uG+W6])=}_bB)͠;15\pLڳ <]'4]4Fq1MN/8cܓOcʁ唆}C*cnXC愫CL%,Sm8Evqp tN7'4`7}cLLK)9%@ŠE,E.IiyKmJ'Jgv׆{ VmPFc? Ըz+;=˕]˜I{mo)JmUaeo;4v^-O :h:9AHbѩ@jTV>ܐSJ|0dj.YG-R%Ibyd6IMKKW=zM1kueId5b]ȰkFoXbtC7W^ nGro[fRoV|T,%.->scnZY TI n=GNVDJW2Iq}9kQxtƹAwA=3њ +̪drN.7B9IҭB< )" jRpw!% K6u4o{}phkj$9I[C}:i]3LwCX>?>]C +ETD?CP=x+bQMsa\ʯL\&բJLz~dQP|qMEx]/k''WsjrF($"ӟͩJ@9$ |A<` Ϡv̲]#{oC4<)=XdgeB`rP!V*7m14}9 WIEmu_.Mma9X޸m<VY嚀q ;Rcyo +k:ԛrXȹbXp|u*/jC=9܋jҬ3JZ:^4Ko +j> H`[XW8sXW]?u5eBKxQc]QQm\5F)B_^u%qIP7,^u5/%ƺeRܪlb]R8qZՆȶM>]855ǃ妦>Ք75 ēM>T;CQwS`)sbx'ɭ^1/sڙ嗅nB]= \>5Ukۜ5DHiaCȍdVX2 lժGUPWKR*Gvm+iM lη +5PM EJ8t5eH髐zvA:Ůt%q#^HWއtLx@2N.j)_dtA>)YayGNAf{r*@f5qo#o0s6jt5/C"~ $ yqHW@#'Wm!v#K +UѮ&z 98ѮԑwZįB"9vghWRrH +:~Ů5Ю +=V]Iwj1,\4X;WZ3بo]^\m:KB{Ok];;1E_]\е.՜HGT+π]m z +]l] <QbL|gJQfl +;LwC +z`Wkmfl]]\!F9If;T}˰a!; TDm5ђ$65 +mNr̻i]k[]c`aj=5svsRmavs5{4{oU9l`W +t)`W3uZ=tP뚇hɗYX <<~٬#֕$h`]a k%7ֵ=F)7!MisZMڐ +NmZMuQmMumMfumMz=Ե-6ԵZMZMtB^)䦁ttmMP妁t妞#ҵڈ*kcLZMZǂҵHF,X#fXBbS#]+T@Jljk-6 kMnƹȹd-̫pWCd3u +>ǡ sD's +JjPGRTz s=3h`̓ I! iT)UғQu_ҜGAxhpG[HW_(Fv@Wj֘tBzptZc}"uGhA;k؟ K@:gIY<CdQN*DЯ%c6ٮpA(FŻ`T2X ګ T]h{|8YOFp(k>9)8%IˎИck3PSSz莄MjjI.*PzMN:%:CqńGbߢiX l.V7ƶi`5r>L9jCb83c$y*&a4j{pcS^}#CM8)@<;>㼍ZX(!5HRbty3ל&RE~>m7(6e.ۣot@#()@!M(@%g#P33\3R7 +H]ȳv]ĻDiCr"`}[~"DMX-{LcA +r˵,x'sB ^\#w#szG1R!(xӥnj֡@JF#tNw+p9lY2i +j2 :#Y`nZMR%ڪ@S@aRE+f±NfCo/BSRqp>wN_pl̕ a*\2d@Ght iDlA2, ԓm`d 4בEI +\iЀÅCt?dF +Q~EjuZNsJN>p +t^i7 RN4HeOȅ+:78Ѓ["BKڅt&7 TtB!шI%h%}9z:@?"lJ0LA~$N>JoP`* tYqy4Y(6'ЕI˥o;SC3I⊶Nr0z ICNj#Hlɑ`]<#M4K`< wZEf@:'7ZQh9;Z%eI!MHa#9shAOH7!IRz!oЫA^rDcƑYkxɛ4b@ Kx tH[`#9piu }LI$8OIh+32 5umL4&[`-&GGgJ%`!ui7}79FrRͪA,``$Ҥ*߿'uC7g4eAaɑ +ruBgL&2ԤyR n$><%]jvPղ1'SW0˵4FR,:pN0]m^Zο%ūVa hQ f X ֠ck+QG\0?f/bZac.8QיZRO +Kh!js YpQ G {O[-oP}<l|ĠDU3֩8P0*TL +'VG0 2.H!`Uʅ$~W$=&c4ކkHrM-7> VCDCtJSoaŖYdz@ȅ ٩ƌ$e1ArY >_w2X@82YAxdTIS S٧񥛒̲V&+/ӌ (CUǚ .r7, +vUT<^#A}Rv{a&j\>o[Vhwt*)FZTtJ6DjxNP+' .p||lލ`eŇrg "wx٢,S6egȑzLeif''MIz"jMn^hMNUT4Dh›8*o[ZW}CWgW^i?:lpfXI!#-MMF v ]C"PrL,1Ё?#d`]$doȰg]E=FڱodbcLR!G4ih$*I2Q*WTvW.xE4h)r Z $)MdR,BeqC +'`"re@ֿ\Cn5K_`Z63HS$X`#e~yw,phmD,WG@!УprƑcgKVA6quE4!ܫL#IXѣ?0j,93bj ɨT)F@F4{ ~d/`5,@0ŭYU(b :|Q:|ZIFV(#t@ޙ~6Qc6_j :MЕȶ`hΰe;Y c2u}WGsu^ۘ `+zZrGbEeQr 6ERjs\fFKi u0Ս{#*\;4@@Yf`"[I%_!@o2֦dfŋ dCH\pP  ԟ"ѣ ;ɚ1`Y]iP֯l&\nB:}7s*`L94A*n_9ˀKgdLh]&UtO3&2V3=4IPȂp1ks q< 9 9O*q3Ϥ#~˛|q:/mDРd)uwZs +8[فtpIS|2`0̐8z` +`0Y EfL}#Js6.u5+ #R4+p9eOMf1}onoDDJ:.vHkC" tr+QB`@ (ck.w_|𫋯⫿=G=ÚXo}M+Ǔp`%gi1'Oe2esݔ\\")ɲ!Q%aM|{͑23*;ݹ 1k* }G bkpˢuv!xq#M-|Ћ(8$2 =gq.+h&Z*W{N?4Ȧ(vn߾޵iIHW˂0_B4E2d`gtN8<,P2쉟'w6?SsujagoyA{p|`l^.\8 *8Ǟ ݑw Ay~F{q *];Bߑ9$<cm^=u=h0\[F }JcHtn':FqqcȠO')oc4mh</n0yNP9ci\PrgawkC>.`H;3AFx֋踶"%=fQIK:<.d^9 +pLX/bߑM7iI`@(izQQI_u`jq$zmI2BWB8yGMۺm=74{jޱaZ z2$iݳ Ӻ'Ջt˦uO;e{9;۽vGNb 1ufl ,;N,F%evX->ŕ'?=oK<$ 8hG=A<cmǞ|5cϾ۔ dV%;ݾ-;ؖؒnjc36{ݦ|rCC>QMrnߒn[>tmcG{&4qVhvȭ"mfIlGZcu-ؑƖxHvψǎdwXcsY;r]o暻bC8j4.-֞WwI 8iuF&fӖ=nM}slϩT;J]5f˨-Qqu-6Sۮ8*M}gm#{6ݩoHpQsU?Lptw -M[6Դ[ܡsC1R`{ۖ~C~Ȗ5fggnh=en sM=m{ܖgm/zvمRQYXdh(X;ܐEmB=Wo+wSx@J|m3][\$$B?--Qn$'+_r-p^xixrI֋Y4qIx< Ǟ%)H70IˆnIr9o5];I'8"l]NNGΚr9(i g>̱2l%1ǵ)-Gyn1[zܜ rS(8b'4}eqn)zqbԝ#rzGz۷ vku\OriSIf.miqqLjvNJLHq-'n.Y%Ovvy#njUvUƹGPj%n*b?2dڑyD,K!O qIpCY`pSE5_Jm!= /

H4 -=4m>%Z֗EYyoD +Ӥ)(4{[q ],-U_g{z0m{xg;KS.gn6d#l&S;#7%'%ޱiuqӎ/*pRE΍ $і`e<1mpnmvMߘvnڎ.cr㌩50sJen*;r۱TvԶM=Tv̶CClymLV +cGkێ7TmwCj;ee ns>POٗqڮZŬxQWt0);;FJ*{æVsa+e{/[;*36Q芯%M}'.vD˻ß,lylu}}mFpVE@qv$u:_S]9ljrONI"9r3k&Or9]yfVk1d7Kwȃ%k}KQ-/q\N⎶6 kmV#-'qYvw$kcI֞w|CKj +}GW{Fj[^↫٣Q՞wTg66v`ZoyjCShjSЏ" +ޗ qj'U@?ޅ2# +u|!6nN2<4ؤi!:q6=P~ydA{FEk5gMfV|bOc,C721?:Ikxhr84? @BJǓY S"*F8C\3 I]3:$7D$4e%_ȳv-:a Fs葕I@5S~$" +/ )M k$#{[8,EGS)z2C&+ml-3c^ $h./)tcbVFr*gӆK^<dcp;Hxh meDr"ܞyPqV ACʢpq=y;ҳf]\AkRty6VkЍ'X +?SS /\:ܗ uUAAaKeslj؄ߛ͹gF 1S#N$y +qLy(zo~L:PhZG:7pa  +1XBR%+eFÇEm\3%D_=v2kDA<|QyNCx9GkC~֤6{VakS֚k?#bX*WD>]t:D6+PZ 4i!A%8$_Y5O*sqR^Yhc.1}/~Zc#Z1@RK͏x %# |P@S}1<֚nwtCs%-=ca6 7QI|:=:d;BY|cQ=j, )p^IA.E(/~V&Cּ+dh@%`X4AKbhkӮ8G10ViHL5%1ie#ߔzh#CӖ6r*'p8gWՀ\.;m* -H;bK`Pg/ZÈZMSrq Hph vk:@ kGjq 0A 2yC\}% zF^5K$$ y}Ht8΄ګdS2tSxMѵmL0Tw Xh٘jD[biC!#g<c'pظ+bpAVUS8 &$SHcW ֲ U$m hLsZ vķ\2@& sE3t}IaΞS$ʗV&eɽKQ9¶s鑋m7ߚ\jf%uh˱Z@ԊɊ,ta1>Y,Jd݁TuZ@9IFPKM2) 9g 9U91'- K֘8uZ{Zg$F1Gl lKndVge)=gܰs=3_0C&ZfIGZg۱'P245BdOVByÌK 9W5eӳDl|k>sW襻6_<{%Z?i.Y1U endstream endobj 43 0 obj <>stream +7;UBff" ULjR y_ NŢ[M!@Zѕ=F/CyЃ9aBAADb'>r:r]7f1qɂZW;E +t-O0j9!t8;fF6\!i;%tM(U0:!"p46~ +KHtzE\Jkꬭm:oqWh:mHqk mb;4@mh.5 h%k#tsM_ƝPwQBGX;jϴL9]No> e 6OSxcRժoe0-1F4$fc](emYq@e+hv, v)ג'q"Q Yft- 2cPD Wk${P!WѴgG/R @a ٝ0FH]=6XY0nE;@Of owv /4hɖEu,7̒^_gl5 HCv:rl Բ#uHZfF]<>A%3v0 ,89**Qk1 @Gtt< ?j̴&1"WOr{Ų=P肫2?J21e>`GLH}U#X{kyE9hU +͛*z7c\~2xC6܌dw1=fi8,ٛ!AG[;MC9^b{=+k$je qbqX9tysNvbNr-C6̔]>4'^a!/YKrCfA@dG5 k$fЁdt%b@F`Cn}R9V"iUEpDřˋGC`5X0R;4 !\Sa<W&М*bXmQ00yhIK4R*҈..ꍎn j[ #* +9oak@/W$) FpPLd= DyI;Jk|}5|fh䪼Ē!S­>meFdAz=B0OZ1bsMtv *ۑr2 COŨ#?|T"s./W>M%@!QhG$Jf%F2= |8d-A+7ܪ^\uU\( ܏Z6bJF%H<k踄p(PAAzػ߲[PK8K ԓ:[^F Ŵy2pWIFHj= +xn ƤM(.iJPR q-\w/?; Cj)D՜j3L,TAͥC/LȮq <gS_%kÕQETYt)n]Zkn+R7avs{0B8e]3p%0sl~1n"aK 䨻&GP%z.aQitijTJͲ>4^Vd~5a!vIܧ|Ofks'oAyl44gny룀** +,*(D[:UvfBO %Zϒ1 ywG*]H ~1#_BY*1di KqeYkTuBR:|8@M8;1$vS0@ zA`j/AlEFubȟNЗY@Gְ0G\ȟΥ$~Mx.?v$K4@xGi-T_n-4fP dGY C8e5&\B=A`!>.zI,ЮA~fmW-{TxnHM>K/kLI԰D߄JTDjÿq|,ydԵ"h,E̹QjaUQ:c]w% +αѺahCWN^υ4H%RMAz.'oMq/K +`u\ 8PN۵*c G DQx€]Ht֢]QeͩZpHtd3Ā5ǽxiMJ42mn7= o.@>DtmgX@s%q$C&5?+$ +5?r-{җN7YSq`q1ԫp5vKr!?f  ~a# +8`Kʛ©嚎 '5"it,浅-,s[^-&W2 aps3YLDAMjԭ1-֌.i*"BhrѾC b (jĘ]*ZV(pQx(19jݚs=Oحx&#WMTrr2.yGl +dʁ NO$1ov +&}qFK8RcE@9I0\8kh%خY,q32Q[n;T_D=L}i% aء1pšn/)+"ѐfe8+%^ԥ;T#vTc&@_g~ +UK]/ ˷'ǻsyaGR#7QА%˱lI>xGpC>N~LoS v7uI@+s8#<e#kYQkok,P7-N"XZX8]DG0`:g +!,0H-A2L1Y]{ wCn~~RXNXRv?3x_jsn54F XJte^7)ܘn-T1D;+ǗR0IkgXg 1ڄ5\w:n%yC7g։n!u% X bcQ3uT-mrRyۜ}j&G61SBf^]I{O6!6uNJ}iV|Z5#kQ˸C?t¬ gu^$EX=h%3f;n>‚ Z,,Y/aO"ۖcy;b`EaG }i! v>OöFn %*[7m.N=Jdvюe~c!JmE3!4Q~T5P"!z^B6"J]R#>,juLf5_*{!U{h$::+&|-c='X8 r9R2G fޕ>Gɂ 3rldᕊÞAKn"@ +Nd-2071OEmqY)-'#I t--h8J.L~FbĜi2bNZQAq |ad 5Ԓ.^hv˦0vb dPs;Я?)j4ɇDO4emXg#K<&uiC&cG˓hق:9,=qp;wۭݙ@n",dCwvcp`@]xJ9n\u\qFDKJJ5 t@pseKկU03EޚC, \֦d7A"[j׽HҨE;50Fm\d<.Q[P].Gwey!Ţt̐cg'/M:='+bV{F +6 jX$n6@"RzWWg)oI}c1#7:au? D2~O鷦p h-24|^N;v 4bOK`9881ZjC3œHjQy^RH,fl-FdK6'PlijBp?ڒ_a +\jQw55Ueеxca3ȮFD$lK<Ɛ֣Tg msw_, `4[=,yoiA}ȥ-e@2qh;qɸ;W5̛: W".Tc ҞlWoBމle\6A4<\K +&"n/[kWo(y/}X DhVPS$zB5za%`ɵ  8vdT|X|c,$V6c,A.rSڍWrnGXt R56`mv~NWMn؞ϺVkC'cЌT )N@v D(l0fXK[ʱ;%S$Nֺ x'`fTW+ +%:Gu,,`nHW'rpNU+wͮ8{y{h7")QԸ9OG>5xVDvLW"wZ(nܵ+ҡ8Hq,iⳎ4 T|֑W[Wfi4TbƜoX88 Msx d{slq~bb5A +Uʣ/9ͽڒ? Ƙi?%,;^ß;(:\j %oN`9Lj6=J:]N<ʨzt;l}],_EŢqUKv5vc5p~%}%"YJ +q2g\t#྆'<:fˬ臬Ͼ"8 +Bw\iU~5hPaUS"K4N&:(䫜aVu25;b7NB͠Y1'_m)|_YIΆW;ݳk5wS{(kWXD89Ы8-D>{{z+@2L{-4Z2N.֌S[C`f͇W0qGB~wO3 Ы<(0pc{z7foqUzbƩWCX06qU(6nGмHwFL3rׅ^NCC=h[SJ{5ԫu> 1~ZǙW|FG KWY(zyUy&%돦k} [m@7.e+c6H3>,ፙ[;_"}|3،ЊIb8: $m;5md|WL +)]U +>L1ru S78$e5lER$CN])H؞ +_{RdWB@w׿fߟ69UCj[vWF&@i͟rV-@qj;0pϴq=:rʠ(-r3\(Ce-#XC6:hw#~c}R0Q-B*TDNJVIFsHsT-:\s:c1fy% +nuj쐵!ѣj1ViO} k$mX}{+j"A%{Z͖gX9~HS8kԉPTsst gv900ěym +ٖsH f + Xeo1?s8>ׯ"^}4/j`u3ydE (;R;r| U:vэlZMy`9A)Xd Kt+ow3wHKϲ=oTɭ|Oۏy1ow3:1MP-߾s}bZM+2꺱U8 BnGNU Ai 9G(, D!Mfy,̏M%, lY ;|>FdbY e6֚eB&b,Mr$%2l=W(i\dȵm +WSwEhүEP/jQ!?skpX܉t}kH*-w,?K^2: +d ΠpK#ҍRW|4p[IaihPpsV,up({f^bw tkIMW-IWxU1LP ):WZP+ cub9t" J&+B(ʣgno9᾽5!QPzA(J.0KZX&[SMS ㉚2 rJ. N5FbiKm*[Unq/=>ÓS'=ڵ&$J9S P?P*rUJ干w#lKvŭ8<|vZ~ g~a `@Df{Vnbµ=$ în!Jݘ8{>AbU9szm-Y۞WNmltP{A4O?fwt.85Δ?1"fCRS{AyCv:oJH|* bv>;րUucHⳋhRGh'r^swO+,}Htb$Y|j״ŘgAlmJ%uܾ(R^xb1&WFJ]oGOhBWy/qџͺAcPXG0lG33D'B=aSOţ)t5TJ{#}c'mQA,<ɿx]GGm<S\0H?7ACzcb!yPL3N!`x39ފ1J^J~ DCtσo:ύKg.5栰0j_; u=31EܙC^yQP]zMGPlO/ʃDg$ʄ`Bs ʰq7dԗq?d;jCylg/(޼I0=gUr.stt,<&G|j D+K㋑c}pTKyN$@|5ѩ}_:DQ!8=qaK(S7;xuPƒ[L; (Bu|F!W~gr`zӢ?uQO>1j_=.oKBȬU9r {Yu:]wQ `TH&`} EƮdehD?xƒiAPDg)ӈ/d?t7.wqWv||Q˩m2kGzh l%Ӫી D!>&4~} +_c_]fILtA2ˑH$|ћѻ-eس2`-u'Xz琗Gb|,G'G7H|Fav G\8R.?\%b5-d5hB婈} hW73֎7TB!*}2i tZqi4.OhRͱpķW8'j3UdKxt?:_m)ڀa۔f(Eu y'&lKg?UA.׺=%!7C4uϗ]lŸ/.U=K"9\^e>!QwSxA=1r/?"@cMۓ|f`e2滺 +wӔ=NhZYY/BS*omhWXzcuxE [ ӐΎp0vzWz=w>2E hoب^p`\.Wmƍ*Lbn0$f0JH mS;aH'wMe'0@}O5ZBIoCd)nz<0}oL2OgOa=fWWe2MB*^+q>.JPma#{IY;g~Ap0F.dZ>*LE2{p,/CYM)'^I&NQ`BPPqdj}~HAҧ ?/ $aگBN*N@;E2bcubє4Eq^_ÊՉY]}b/!1`pbi79|80%:j21~`Bu +k0ǯ *߰bNWƮPUlo&b:.ΆD#q93^]az~8/-eSEՁ!i̕!SYpbȩAXh>F]8lub@i K3X2[eNx#}u.FZ:(Ī{'nݞ,Q0!>qybY +'f|bd!ŨaGl.NՑQD2n< EƃZdN̻zitXN c#а3?0,'`?T.۩{h}oªEYvD/;_/os30קѻYA^k^hm-y!Og8AeMf_x3qՊLKUmq|u7>Aa'^"Vo 'bLvQj[n̗d'ҥW ewEP Cnqe/n +\jU9^UVq,1h&|ߧ6{X)ꮦQխW!bϗc~ưh]q.FK1| +;tTtS9&ABzb~zS +v62VC2s2d/}C PH3M xP`Kq_c!tL2"k al^aە ߻Tft=F[Z+`?uЋ?N=IN:E=U?jq?̽5kB:`j-G1H4Eނ^dULMJ#AA4NI;rk(D%mڦ8ŏ^[kGZcpAU"ۗg@1dW7q{vխpNd\uxEݔk1O;42`ѩhGRkK`8U9Mq#0_w{s=:> nPktSp'4 Ӻ> 5Շw a=> pamchh}bbZZuSQ0YH6mu7#ط,̥v/f?m1S]&ysrb;acwwzD3B h?.Uf.sLrIV#bF KK9eg<ҟ3vUwٻtb|NcXيϒ-B GV\v@HQ\ kpgHO5[zvǨY.P.#KZ4λ_n/XDfj7^/~z+ϻiZGn]LI~"wIH5~4rG_V_Д fsȯ>vt'LY( oX7ebQ,W欐^fz#=C &E3(i9`rj"H9̻ xtDZs0ϵGX]fN2{xNUd7.>"E!D"_[K0w` y;.Qk.Fi?Mn hkz6Tڄ_`mf|՚'kNM3Y-)yM~aAfkELٛ,7 k}$dgKa,W NTE2"0w +0>GfJٔ|08`G)]"3C/4˗ l8L%d,i eCF}tlHW@;FDOz +CLRmzl)(߳})[Z8a)h'<5@kz<%qĶmo]'J4}D8m wSZib˸nm*m%]/iDy%6q 0e#H5PJ3G۫.eu+ q0PoYa͢y|)ĄA +?sɎ*n +1#A>Fҍ1&Pߟ2dCNjpJ|QLfJEyi,Oe/GpY3R~AGHnu%ْ1}lI(HL6[1e}2Cޟ^-m>͐6nW_CW6>_>Ұ⏯kv g;<( +Ѹmm_֋l-_[~AɼԻig+_Y`+M+2xgm~p׀7ܒq5{yD߅fx &+գoE[x1y}.<^#<2x iۛ Nm}x#ɒ}5(]?5 % $썮 B' my ;3vf+QB>C^Qf>!jDZξ/1PtƼ2:WX.v7z \p/:UvQ][ͨWı(:5]Z^o5^]J}Bj"}BL#;YSj Ԓjw|L=!r:RNUCYn#U[&3컯("_fkVrjQ KXՒr|eᐽu|+AAFKt՗3"_\͕+ ~Wou15"4jGFҦ.v.雐:v;I~W h*VU1A_Lj,jf+!uo$5C_3JuW[Վx4?w< o)~ۙ +-YF>n} DO$s=_9ZWm 5_ jnt~ tt"G\4~)ѯ?Ž~E96W0.Ahj@:>,MT̬:P]'Ӂ-zm+/ߝ53v+gg73;6`1 3VlSxY)[ fTaĿh#Vj^6`s3`s3W،:㹙as3`]ig"qv.^ ]uv! ]84vqjf Ԍ@ű?Of.]ú3vqnf. ] X3afF.͈]ƺx3vqnLf.]88#v>8v>7 8Wuisp>+yF*BXaє&<#K2 MQ$uϐO^pW_DâוfP,Vxm Z#(v-@ m~ &(V(MBah^UdŮMn(xVA*mRwyM +HFT"ᬋ0v.R:s*0b'Ӽ.•X4b ]0v +L`Δ E&0#cl󄌝L +;2v +jFdL؟C3Q3klƪ&4V%J3bc烼ƪ@-NHfllmW Z|V<[sт!PKz |R#  V9acߪ̈Uy% VALxS?{DǢË@t #:6eljuE`6c1@hP'Wk,a:jh@}v]ԑ'좎<"du!#ڭ=]+G]9#d_ \iGaȺ(Y?t#DVK=V155cda phYGv+ͤBCqz\(K$n%ɭ$J`R @a S YoV[pA`^@ZbܻuF(y%_e;ٞ&% az{]ma=cH39i*s^8M8ٟ98e=:#R3ڼ/3Bey$8KZ|CekakK"g@4vfJzN4AX3MxOk M ]$`v΀Y/2Mlf 3fg.,]X0<#`vayF̈բ 1xGĬQ6;"f$@CoF̺QFĬ۞~fLٟ'3cfgV%5fg.R4ǖsXpzjznQy`GhȞ>Y +_̠Yٙ5\AA!/0B=]ĥ3jj5fE`LC`Zuk^p~Zd+GJK-}]3pvxE9"g ]D#tvyE9Ag爝]Ğ#vvfمxcg`.]8<#xvd.]<03xvaGٟDĭQ?N3yٺnhlò©=kl{@b..HWq.YZ1_[(4Z{ r1ۙ"\{QŬ8dM٘ dJ/Rn䧪Htx\߹>vUy1aiߠeIS[BE}=o0DSuA̛u + [q-yf32Usv!,®f*dټ90ȷ͎H@I2Z2d`w*Z +9EonE15 GU̜WϤ%j.e'v?ߪJ\~ȅR`g( tZsXo>ˈl~bo7y$\^tҐ|¾&ׯ՚(N#nU +ͻؖDW_2ZC;b?3gKj̈H`L$: ?π<bjß]Gd^*^pLOke̽mKmH a,6 >9ؙQ~FRP$AAl"[5n1~ E]쫡GRXao魝@MB[< 1 0A yZ$kys@6[‰ltU{ov +N lS"kqe y͟yd a + xE- b(#%xe%"lwbLy]ȩ90yG1(T_t[+,VWD1}5.E!g fn}k].(BYsH̦ sG<*zxi˶"a v›A]qC.j-@zBwHĽ+U* ocǪث)k.7_)7)AX;L M4K륈]C+6k:oqj͏k, {V~Ajql|3HD +v0-#W䟮z]ް>-pVQ엄}oC-Oj2>&WAEƒzdU;MN)eǘӡYepQ 3!\iHL?avS$:]9}+ ( nwnz.=ۍ1M8./b oR;Gxh"N%hUozc7SG ԋ^, !`qޯE;L,^ 9 緛 N-#efm#)@3y yA[ Y$GT_n$=0gEBuk&PCM/dimKe&:3ߚn>'W\/dC6zm{]TCls㬩gb؞3 ·CK Jy]R딮.BG&K.= QfFAk%4W/a:͌RѝgD8kƷ>͞KaƸU*iŲ#̣hުbӜ˷g& RpVDzZ=)T uÙ{DR/m R_)fb DR^pNmI30]jŨXcod xQ$U:;Tg{Jgrc^Zvٓ +WyP)`ӭڢBAߞ߫);5&f[k""a:RQ~5|F-RdoLkD( jTjF *h&Z͚S_HeU, %vdڮ8͢`Shu8z̫ ܲ"f#;:M6e2(ы(hcHce̠ibӏ揯A/-︐O.6sZv%)V|TWޛI؝Q(1'Z~\)D0sݶ%>ҬӼ:RZT͎"/hu"jbV/O6AvWKO2YȠ@&*I]O~lY|giWAxPfޜm d7,C' +d^>8F`F(}{\zO]tEd[=Ɯxv B5xؼI5$P)+=D#+S< +wT9g;dӚMgn;dV ZAٜ4g>)=B2 C_\(؈҃6 nn'bf/l=X +nd2{Vd}]C5D=v.XkJW ЏeI^[zG+wij~:pr{yhns5@Lkjgqor/n +=Ԅ`aWKK jIB;,#6&m3.T&2^=u.o>"OGwpv_ <Xf;+en~] o +s5;;d5r0U!\r+'\ +ҽCӵ;8tP~F/='ZЏn#5c;mM& =lڦΧ斸R`KCX +^Ҩ+͈K).֨ Df>A];x-a/sh^xы@8ݔԬ2&BJt +(Y)ہy*h3;eHAѣtZ(DNbͩ ,`TRG A,F%i7fʹmǫvFCBaL4p'# >m?hh'`~ /nB%L=Szv`CBD32 grH5Ȅ~P,uC^FYPs} DʢEQHdAvYv0+r- +0|r!;Av +B>_b{=H{yaqvZjtԭ+v@f 왑DgRf/S}/f>?㆛4UXVG&%r[H2E:9p6&!%`Kld{Wy[-V +!,`jg^*Ga7Jػl. bLUÙ9>[laFP^(շsɷz :zlU\CNeW9.wf!-JͩRR52NiьEH 5bx:W +,US`(ջ;yGy ڙYu+h׷=wP(P5Ʋ4:?8D{5HPPGݹӒ\+ -AKq%"KX\Do.B.X}Oa߫jHk"a(-_H} 8y])!fGO,atJ{Э=EuhX r8׶/û?oDi榫E%{꒷q +g[fHe~W/Ձ|ډ [*m;x,܍% +*҈Mǣ.L] ϩʰm<'3ԖeD֔{E9%#3 ݨL1l2q<24BB :2LfDZy]]<P,!ıኁ N)}տQrl38_Q>cn.♍ ?O;CMy lj.)MP/r tJ bF{m{kKvrnK;/ĩ]rEORr_A)E5b&yZUo6wj)hsVq/~+s"z+-UUI¯{fQ'0xu`at-9逊Ș hm- Z0 dsPkd)rZV憊FSV]\}af. 3rj 7˙KNAZ~,!x1BAFY#zZa$.=gӍ5DPMRAv7~+NC"΍ '׭Bu~W[s5&l}v3vN)t>ު(Mz!^;U `VG"%; bcT UN!q{TT0< +$sO4Iڼl#J1ܶԮˎ([E` #C|%Ѽ0:)2ۗg9db0 vA]ӟw AvQ Wo(hiZ?(Zb/NPޜݙ&o"12w:.C[Y-&-a\D`{9['C /&A>I3e}%>Mր!n^*;dB½9d +Cи=j'?؛-cC3]`0Vn\ퟺ4WdPDtj-c[lPT)LQ{ۣ3!nm0P3F*ZCq㿝eXO1{81F`nSO!'G$٥-

UY-AռhEQjm}"^m\d7U7x~ţCtd6_;Nț"\ +׍-()+ ̢A`?|"QGȭiQ-ezHzNHiߥj@"cj(L@5=3;ë]dp?Ƶ we('hS\RBl +%W3)P^>gf*œaE} .QjTI^\Qp©b{vtECv6z/ScQzSPj f 9mwe&r; } G6oar%dpNE/@dOxўHaO@HcJ6ǣc%(so1@rn#EjRs?r/wwZ8bk 8>B71-˫ή3r?5TU5:~2~'j&"^0Z.pt8=;'B6^I#6j h^T-l?ۇW0QBM,6^)WsI P + IGS6u\3(}^!T5@ "ybF|NoWxk551LgBiY[cŜzo-wm! JHb;(~4Fhdy ܭi[ 皿YFb8K"%L<~FZ^2dFN(xȶUi` U.>;.3h) 7^*_%okL,+~^%8ɔbq@Q)Xv03L"bj|$b] qݱ +&-=ki ȦO~{C_gNjv޳;"R#5\Nzz_EN_30-{5O3_B,p.ٿ0}J&jr,Sziz{ç(;nO%f:>U^>E +Gޕ[*NQ`86x<*&9k3LV5ܩqi)c~[~miTl8v֧S}"V oQnov: $^u0s$f>`)s4h'}9D0r +ҹ?mIS +#vi4hS4oJORm9^0n*L#9ӦAq o3Mð-2;0l*{[h5JO6^>'MQmG΃QS|m8g(o3]-LK!50,͈NC![ F)`կ2뛷ep0dʋ't3SN r[)Hr?R3#;FH kH)[~T&3򕴾H%'CNQM4wFt; @ρ=='N8{h8M"C2LX̺>kx4*+TTw +c8hkNdч;Gɇ.Fw]F4FwX5Ξ{H.ZϯMH1<&0n#=A@QW+1qAv&fP)^w> c~F[(BfGZ,[})B VL's?Av&iȼrhoϡٽ2VJ`'ؐ{ ܘ1F{O/=/x4QqZa,$+ƔE(N^[1J^Ȇ,с{b պd!ul.>jZBܬc "˒D.vQV'r b7vNb$?sV,("*fҏAi:l9D㏻&5`AӦQ8٩7EJHio\ 0HWMRx'fuɋ8CڜTaa/tD6|&^j5EY 5Io2fO_8jǃ3W>9hL'B߻1(9hf^HhGO{ELDo6Dq6gٻtO5)I_VcGYJޓJE V7#S'(1]IӣhObL5D1׷VK$w +6{g}!X%k,>4 db* {2AvBf/h>2(o+O!"g[uʊzn"pAȒoDWn l4,VLb5f~FNL ( CMԦs*z Sr(ޯ ]5mQfAӛKFx}iL O{m)敔@sR;F60QE^|ppּSuAM^\ S;rJ[p ?%`'4/U`&nLBbl.f52ScAc⦋ʾ\ΞƏW!G I:/u9;?_]2 KCS. b9ee^ҳX0.w`w90yuI- nod+Oo9tCd1E3iKϴ-ʓo dSZY̳VUA#D##ңGs{joeC; +:AMGk_Gu*Xe@ԁl- +DܞiScy +-j]Sx5#Pʎ~)m[~r:+;]Qܮp]'% 6@_x齲k#Wh(>9H!Lz-˩w'aX?=Ѻw-T/3߻j.Z!)B2v@3_5`aO 5: /5yv[^Gkl'áPhא` fZM(9w`v`K Gq';! 7S3C"(V`oJvT`_+-= 0{3,x$aNC%[;~*}ewpئ,k^$`w9#G麮M'D)h)sV>JA`9؀>'eQA9-F 7D+11KDm }-4kުEpt@wg@:;k貍bYW N)eBPÙL|&p'=e)P{q1z;֭ <: (d[ױpP5)ltp{]"؅v +jzA U^ ;@";$@%HA#*fLD:՝ʴD~!K5\] tż,L &£DA(PCai_+KRDpdet`W '<ou%*}3RB߀+<-YulLgux2f>e2OY4Wԭu[Z@յD8pZR*ܨvL M6]kaաq!.lO­Լ XeܻEXwcunc4\+*:Qz ucxrpl T̟CZ¡paD5yCZZ % 1 +%vnWz\\@wc1TX'ug)ӡ`YthDé+pj&18Xtf;OL: 6HG{OF\lun@Qk3-Y:eadžCSд܍H)za +x ++lܠmW"MIEsKZ$ݍ˨\CKftn?΂Q#/emRUמoS`&>x1ukoZ?7b)^ڜOyP +OW,EUX&JͿ0BbPˁ,M;Zˮ(bO~0wQMFY/ö{aQb?խu%9X-ڳuD־+mۼodd{Tþ"4ͬot|[+0M&y{Q% ;&N(Zd+:KZsdQMKv?ʝK2yC=DR:2ŸRbНud$]K鑋udؾA߽[I3=10D-'ajWZʩ]IyYz َ߱KVb{L3 ^fo"sORfԬ`A]+-oҢXr^͎[9rIy>;P]wsjF`nt{__¸;_RH/:A?M3N_kݦ 8^4L~'յxp:娉En&/&{sӾԳW!Ҍ➡}J?dN=g;!v_VukvQaV  -ޫYs[2Nji3{S;דqY|D-^7Z|^|&-B Z3\hJ@52IJ@o\&wWG;F`nL.Tbd2ihy }֖g/̖tZ,05R?ZFlyLd ?,E `^7hl)#}c}ީ>.)L#Ti͇] wۣ>6P Cxb:xjj_Ooի6!0H Jo}l{v[[SaeS(0H̶Xc @f`RW;6BLRC2 2 +{E!W 1h +L *T!# TH B^"`u(R\NQ=S\Z3.,׎:wil{U?yBQt^q:d~ɫ( v_h%^;Cs]9/`[́goUn/t +Z8p4ѧ&;[+͑'B(㪠)7aد(o =Stjx 1=`''H!گ=WD=2!9:؃]֡'3/ `|Ulq!F柄B5;h<,Ѯc_z(J ^ 0yg;lj^`` |qvw4͔/p.K4r qŚ&H_W_=jOо(='>eZzk)ہںZF;쐀BkF? %FjCkFV_q@&?2jԾ+]3VGhgqAsQ*GFLm'Q/mFyP(늞uB"LeqF c58g3Ҭ"N[> *}h xdy[`im`OYFA?xFDyQ}fU;6eĦ/3'X(=R<)zhpHgFuIvyM4DguKW +WWA vXL@kF[5gt=~OQ1fYX)du< 5t Jk VyI"ɝqتF+}uׄ/h 53S3 pOTW`K~ +ŔRZSv0CݧlB +r~8h$,̲'0^POZf愷B˚/OI+sX$ *Z{*PѲ&Īp/Gvhd6ƫ r] 8#udB. | `77[ct>l|2[2Z񆵦~>^u5a)<:oP_ǃKA9+H+IpD`Dkq eo@i+U45u^kRUP~$B.3aY#LqDWPVLɺ*Hgo`_Ȫ + +g#U+!!`5#&֫Q5Bh6Xs#ͲboLE>Nq^]U] ]`W]]}.b7nuː}X ٧L!Y]du掀.˘}@N!V]֯6n*YMXU>z{ڊ7R5.ZZU>1 ;\Tk__{PGm콢8T9T#q7HjYDUu@J4z4#TcG&:o|* BP?*oxj\}{_TWZKr Pjm5ᮻHF} =/e[>{oXjpͮD3x~MTwQ$N4NgXwy, yHjC:ׄH]TiG@J£./8J;QkwOt}hーyޛgjD=!#6I_5yqْX' r H2om,퇜 2i ӟkyv֒Ty/K^6n~Zdi8E8H ?rW8G =z<:/h\#rt/*^p >F6M+.LM 1 \xŊ .0Zta_`se'l ʠ/p]k#0eIP; 'FGRZ*X8vK렅zu0UMT5du$9{ubHM*$T?] !gǟFR¡R;POuqRɘL{Ґg-<:r"]5h Un^n;:vBL7 .Xpί-y Zd:̌:.`в@lIHFurHMV6FE +:a؜F[`ưb۞htmF]d"6, h,/ 1> ΂@!I}HEM0cr%@Fd.}c_U)~ 2_g^kL +x a;fz z`5^c` d ԗdp'Mﮖ E-)DJ"r6hY@3cJo^c: q%a*v&xL4SEg #(o2A +ziOT[ +E#Yk2@iWȓBcs֞$kԭb{{А>点!ѼU$.pPq`'iN] Z o>J{WI%“EgM+lGJ K"`55/Ɵl\$`9ˣW ;)akdp$g?BSNѪMU)[t`VXK8<Sx=Vtݎyw~޴l&[@Vh<LTDTr *nc7Y,T,+/͊nj:_T],]u0Ht"= ^]'@r\Ů6⟟OkLZIV$WW#SE]e%j7 JYaRuӨ'Pbd@\|fkc  =w}R_wE'` +(Zo44TEǨ Y(f|q| m bG_bfJ8r@╢ț:CȺQB(q"7E=k2kÜrE$ڕH8jJ@>%$$bd^>cIMN6CO4LP<^N] 5gA,,P" +R Fک$: 1`=/[9fNiQo^0@;(3%g?ȁ z=k'uc] MyGMl^+..vLrm1L6%[ȑ`Kyn*#hi&Q(ϙݣFj G'h9=xPu3($kK;&N ;:|vρ@2g4CUg _n.2TKK?GO.ș]FB'L" V5bm>ᯝ>Ձ,}0DE %3V-[#5uޠZ[71'bnȼ.Òhz PpI@fġmD}ZбOz!GMߢS|Ly\ v!)c(,SUI0z6 ykp]*o%0ɴ6)Ԛ@ehgdT\.i%,ԡ@[uŇIۚwVspuk +Չ\TãT"$vY`moD4^@zq@%8~V7 ~6AyjBƭ{g5d$jDRfV{rroaE-"]\ F +^;^c(0^Y]h +(]c2+J^ }S^-ԭ]PJ[+i"b$μg;z*A }[%eʷsd] *a zu<)\r{X&:/ڸi&ǀޟ՚L0|p'x,V?.Ԋ3Q7zweUb +.FM & ImGiA 7:hgZ5,}/.>+Hktny`lЃ![.?$xm-0)ꏺhAYC5V&r=gxy8o&+w-b Ͷ8V<1`bp¤RYS5b<Âb +h.-.PEoxbEl,eZ;tjV!a^) +BgYrdP&Dgi43؈vzOԯrl2 ڪ+C[(518{?r&z @'BtN0,ESzm=ƪX/$!oR +p6hl/Guѫ ?I='l +SsEҏRhW)aE{~¾疒pV"䆩c粫9e'w84HQN{l$҉jΪFV[dA DZkѼhM9fm}|~@M0kG1n,`!X2\ +3VK؃nA1fD>xm@(Ēb)?$cWa=gx ;|K\idjT ^#hؓ9\u|>ce Ra ۙg?u^sT瑱IgV}W=׃HN|0Bi(kSjm(>'ߵ+G=7 pٳںPnj-C,D+T29xl=S iuy%uRU{LZ#԰74. 6ciI];be<ix;ͱX`FZj2%=d'lhdx(UCH/s=ՒZzLgJ7UFt/lT$83%x@$Q*2B3hU}\>TQjf M7ڇ07wj?D>TY2Oy MK"}ފCco+x ky),߼Y;jZlij]aZ`c쁗¹gOЎv%1ߔ=,!,N٨(/U]T7-!UDM>EVt;ۑ~LN=ڒtρzC HN V<" +xB6l=]Q_mn>Pc%Cy(q@,eK?$|~SݳQf&i7Qۀ kՄzԃ#WrޥXdb7  ^ȁ& 2A +k2~PXMͿ5nTW*S CEsEg]X*7N +:exɆy@=  텮U+QK1߇_ Ry 'eFab x^*;lBtZlm#Q 3yQ$k$ :{@, b.6 iLs#Uv,@gG4^iô{^CxcHBISj6W% o ly5"3|^2)4ʲCLK BP!pl1 T2+$*iI:Q)(7v]N+Zk"Va&狰}ɬ <ʞ5؊'*yGҧgx hâS8o:[h Bc=qkITBgAz"h{UF6+&nys +[x*<늵QvTq3r5I/ E*Rp=BN(ާ}t\>Iz+N4U(RфP %Q557{@܎JA Xy*(O }@:[C蒰I`,wx`+=<hutᄿ<U\I%B8P J\(sDkx#w6>zDg+ qfd|L]Cku9#u.,k`ъߓ{F'wX[4BOD !%%߹Ac^C3l -+ƾ[m#PZK@d:u{9 7j(ȭyP Gat +Lm3788? Ip]5\\D u*044IX+AgS'W:mR7;d. .)q[ +Sf1yyAssm1>?A0L,9S?xUÞJ5/SsJpm*O9&ձҎcIӽ8PPO9|L&Rl^QVƭwk#bq ! <ٟXCaʱدq4; PC !r  (mⷶ=I*m'8ZÜCR̕ZQXAֻ֌4n)MxZ@M=\pwaR\Ponc^5Ա+RSv /_>b.Mtr3LLWP<"4oSSeIJIJ +^fsCf/5kcןXsJixe3k6b[ 4ʩ 6`l$ąS !U\h$ +$thd{ n!rd>mpCRw3ѣآW9'FfłMDŽ,WMP ;q~:iИpWe-42mޫHGl:Э\)ϠE~J\ a?ݎXO f%st\ԡ>M/J[k6qYfV +!; +ͬ vds/J4g4,Z X9M݊ZY3Q+K2̭4nȅ[io"33ܪS~"׊{3Ҿ 'vB5[5+mxF]KLJOJ̹7^ؕ@,LJӁ6L®PK+=}h&zGP:SaJu;ӽT+kt!;>Fl75􎱺33,$$CuMdDʤ}K4W%xh!g%|\WjX3rfY܍T>%HX Rnl8³d|³ԻG4}lxr.g%ظ#c1R-k0t0ϗBo3%jm_`U9=@rV%f^H4BӭLG"j56+/ʭ+]kJ +qb؟XBڗa$dznL1-rv_,+4y8nVA$`_vIIH,A U7>=;kˋn7ܘEQh}K*7$4<+ݣW2?`+RzW&)|%EmZ=1svԀ=c:ӻ=+jIShb+zhnE'ExIKx +Zp϶ +\1([_%J[:Zی`wOD0Xb/$g}4үlE4f:v:J7P~ +m(dT[AZ.t@>V4L=J.@BHt׾]s|iYC5GpRl럿m֜0qB0PYQ%֝ TP5s= +ȵUf 0AW{P?JXr*emqA >Ҵ[t +ez4Av$Xq*[ϼ36Ľl!w>=PG2ݳw-1][fWF~˸ Ӱ>x#) 87s +]a$! +M%]ͦ': $艺-^mzۯ.ՔgYIBmǣ4/WatCoP%`)V852)> ,Oui \*pŠ^(DžPґ?xNk& 3t!~%KCViITE͞RWmwPLf+#S=z|;()5RTA|"ԯ>|x iYgo q8XSFԷ#4MgGN5B*HR-n虝WAF:Z%$hP;=8֡7l +q dZڟ*ǎQ1k^<Y0n 9 PpX,5ƒ(u>@Pf-CA$_?gB{^y;DE[ꌽӄ4=F*'<`A3:XiyW7Ϡn_vꩉ'*gw3"bLy`yi;,f3™ՃCRYyyyJ }꥝. nΌpf\2gD8:egC1x1jSM4z]gIon`JWe;xqڄer_v}*1>9i,Z`K9vXK*k!_1:E8in5ۧX9EGe? 6ҦbzB4|~wq0!X(/^1$pWK_/&E Yy UM{X +Csۇal1|Q?#8c ohf{TvgZ+}&0Sz SʫBr5)A3lT}hExH6lW&-ߥa$ajbj3Q:))Z;7rLyijO|K[#=Ƴ(j 5j5Zh{u쥼nqޑ +CLYwz`k/gV.8FSZnn^_ <Rb($UgfygֻLYmd^;s{cߺM)y,p)sj,R9(?*SF@㓘US=e͉Gv ݬ H82fk~)0<I܅U` 򳤗Ȣ%GqT3}>MQt p.;BPO&o>u31=B +~'4`7W.yO5eF"^IٍlK +tQ1'=)i3 ĝ9(u$Y~6Cq\gSf>詒K,9vd1g)CeAȧTAFFhۦ&\<ŲW^qJ6q*8I ?KB5)0WCr#w~NJC`XjdIɤfc0;Yz{^WN.Lgev=c`_D\W78U&'``!Ãd+%G_["hO +ۄ,gw$@;>͎V21/.4ʊ#kxPTp壈$ Z>vS;~ ZV]2̯D^@pmHnx1b⬡Դbs@֫D!Or{@(gM$W!#rΌ-10t#mnc5%dN̈́뛛Vl*Kz< + nGc`cӑy(-Lb9H8ԆvC)Gǃx̾v2ll;bB7}@ \6xG:e|WY|߃hl=MZZ2mţzaHt{>B`N dw@j-onZ=UA/ +ЕCJa@Oʇ"Cq+B=tNԑ4uCoDӮY' IV8̤eccx/51=hn&>x%˸=y<,)Kk꩝(2s1Do䥚{^ +kړ` '_i"TK4J pzg%>B8խkiBrfC/UDF Ӟ(iW,{WekqmGwG[ T(BvKI <wN+!|5dw""\Mn`81mȮ S)6&9$.{C#*lcvTh!mbb akl;W7؆U u[T֐l&y[nC6"[V 7_ΰ\\ +RQ>Cs\$ DqnBe{NkU jT +WPA9F\}t0OБT2K$S*IZu˛f@N.Dnu#.A~xZ!R[TppY8s^k:.iI zXCHooLeK-M0#sM|g8iݝ #*-#) n2Wr -UڠE=cXA5>۪Ηn`X@UtĖt@Q'{4)ϺZ9iu_QS#t'vUOc7ZP%3r0@w=ˇco7kC>3 OˏC-P@ޠ,&1{CnNS .B-jrWtS+|};he +0!{rʟSNw,;!"-a s;AԩX9|kK?X w!ׇ7xg{]fߥWo' v C."O=sJ@^rP@Z}8KjClojA="kK ZNXM|7?+.P]%t\ϯ5Bb$UQS(5f0 J.y z(٦PZ@2)@?u3-^1åU9xޗC 9pƴwAWM=G`WZ +"j0VJCreD{K^WY le4*KB@J˵^YE\Mܕ.:t +rpѱ-#bٿ.Bj_B +B-]O-";9xџY'_>stream +%AI12_CompressedDataxku- ?`@3*gd5Ϋ<ȶ _\\4nZ5lh~vD>b[EɈ\{_/W/_0_߽|_t_wÈFǿ-7޾a߿x7x/~߿z%jO/o޿n7_oyϏw/)z뱲7|7_Tǵo|No_~ǂCp|^~s04iǨ/=2<:XR`gCu5Nޅ~_o}O//}8^c$ h^?8a>>?}Weo߼?ۯp峗pCNq01qx1/W%X8+821Zc)uadp.')q%O5"n3=$y.~z)j'L!kx1N|R\"*o{WZXfm߽+8itܸ<:N>"(V',ʹKۼW_:jcz_Z[[N9V|%8`L_u,\Z.^ǯK[}w +-9Y͗xϾ}~5g뗚9ך?W;ެ߼{+0>h͋@.a*t8}9Nɝx|<^x:X%1=ʄPXrF9գl50TPrE9PeQR%@̞PʘQ*'0)s!xtBBEh W .є?#C}{F5u9xxĺGޮNyE4 l VqFOnr#HȅN;و^ht(b/GJj_vC)8d37U֞[Z?zo ?}2[ s?2J˱S+\ZK VVB+zQcLx͂/  x=K3*d将*arv8c[\#ѭ-)3W\5G윾}zԞث156kݪֵԻov%4aLw-O,76b&UL*n䊅'ܝp;8k: m5& 1^x+`ѳ V;"|Is˦;o=$R$P &gf{+wer0BϓG [6[TS2Ad tB٠UkRm YBG.S~X<4N) FȊG2`|ΛgO&'HC_g} +H8<\S^"5qa*iA +pi&F;:OyW'$zd9 #{K#V6Ч֕ y.Dwx(J3ygeʐ9nH'=]L/'+re( 6l?mʠVϸ*n|)u̥P$u%)4 d]br\/q%%IaGA]2mFZIIm$I%PM5c3\$TX-SM. "d=\CY٦njB"Uw XkϳG 0KD ͐40͔CCY*CL9l9QHXL-$b}Cx ށ?dBQ2@J:K-Hd;_1 WWIԞR +mEE;>JSuulK$CCnQ Wxx:x^/Nа +xj^!T!c!kq392BFP :zSguTTDOPYĮQZkۡYl8j-^J\7^gUkm1 0xi+Fm)0 glJ9/'}·ЖEY[43Ϲ4gMMxT4,nJfeߡB96exw=~DjFtc⪸M6oJؖ7%mJޔ)uS H.Mlq[;_u2mߔ)q]Ǵ)ySʦM9ޔqޔ˦ܼhF;G/4$GGijQ4,e_Q O6%FJ<5;ES(kZ/@gLr۔<)]7kAWfVvЕ|a@EqlUlTŸf*Ќ4CΫ6ogsFj[ۿYhcю~7YAM=5PlpPM s]ќNRI-nMhtRĢS$ Ļfvr7l;xClKx Vm_!oΕU^rzgM5IKqA+*QV˲N}x <@?y϶,wlWf!1s.ktȶ3E轔ITG-׶R`NZ_Lm-F)4Tish8 N]Z`FX Lhꄭ[ǭ%-wg7~Lhc6YM 4S%*c{BU#/îKBTܙ=g4 O̞Kc 2JTG +{xFjޯ h[ic-LymsӮuo|gusz([Qr F<2N9Gl7*Z9U3u#_q⧬'`x%X@-b(g;f༘A%:+,&˚ٵDk&R%@m0ˉ|D[Zor! Xrmi! M($Sn]g$WX @O1,lL$6Ʉ i*,P/tTg!ſCk2 Q.t&z7EC^gW;+|ys{^W+U@EXDU3 `҃Mzcq1{9|<|<͂|n(4Seľ4GsОdC4e8،:Jz#ws[0 f ) 5ѤQ4D UPf<(9t4clm?dsiyhS66k{o|3?~Xhİ&Yb$,F,B"@,CY^XĄE0Xؿmjpf +?}OfFv/U˭ٷݵ_7ݍWnb0Kk`Zs[E1^ccG `xq4Lr 6=F2Iˀi$kG RJz32+ړgӒX _t{UI<[r6Vifs4Q)\7.{Ek0ɴqtw@+pr1.:YWfے͓?^7X)uΚ {\aȸvS'L,nT/#4\74U$&d1biA +蜁NTGqŻj1I>6|A-!VV[Py!'2G\3]h]ٜ?HF]gtuRߥ3Q˙%ƒЅ`m!?ǎcAO .?Ch*LNyELhu3\a5_kB|0*ĥC ++O~g{ l,WEVsG+|iRkC9f`O7τi_ğ30Iu$Edvp+@d_6qi,2baË#Z]~Kbdԙ:ׁ9S), lj>Hq34 q +3kwU ı r6`]luU}:k0&Xo"? 0Tװ=N;Ƹ`.;k*^vʓ(mGT6>Usexw+v0VhϓVLq]d Md4Ml;`1Pd m#C$-ѻة$qlչ&=CcR\2xEK]f^2CBmO-ifnD7JKY }m垍t[$SneX}Y҇MSk/õ>nT`:d+eY/ 14dWz8e>_?qhA;7ͦYWўCqgוw~bf(u6xvu6|z.Fӝgwq_To]kFڢ eՐӲ[4|s}ݽ! ;e]_n_Nw+u}|p_ DײBG^2,FE2qEe-$%f6D7>VDœJdmtM{M~sUQY,8zsovul34ic1bl`qȚ34ټ>8:*yU΃lxSN2? ͏)Oqxj7Y`8_df`WqGti׶3='i恮;΃&8ǟ mol&Qݭp;cۍ߶u05lȽne`rgF@H R,"9 NX Vz6eCX ]l1> 2|zSNSk/EЇpy!Zuig_)W%YHRM w v|tϔYl~q0yqcu*T4n{s! NDyJ ,l(:TQGZR܎Kg8~Ƈ8}3X.tpfboM$)N)Sɧ8Oq$H>ő|#G)Sɧ8Oq$HOq$H>őq$k椘ixnԲ sMU·:?7׋B16x%96GIɖƚg#Hy'`15K2@QnFG %'"BA 4)c>* ++l݅{*΍ldYY|>](n2?KJmf + jYn߅\|Ʒ㽰Ma'U*vyt,Gh,y/Fݞ6[J; ͚g6>Λ#9~ueQTLQ,IrQrݏ" mHgi?K5qJ~0l cXƛG<ςY`~< v_@2yoD#wܹqu;%Tz懫~Km[LŦzbz\}={k$ʿ + 3| mJnes\aCSSΖK>7Vv&b 朷>oǞWs%k.*X7|~ kC|be9ÎLbbb LϱԖxhv^l]]2tO 5S32ہ< a4d\_)9nRYk,/%dGZ9vltoCidCu[?8Ϲ0'6ߥ {j͛g@ۙ14L,{\gwWƽzW.k +`k?囒6%nJXzlh0ꏋ7|Z??7uNYy͇]Xy|6T9ڭT:xhK(!PڨiF7s T>n@\:OyS;D@oqqdq L,Bt NVy&I9ۄ:7G\35ZĄLgEO0̀I+WJ.9ܥ)7e}kwkM7"g0 le}֧]H9O{9@I@=q B؁!.(Sv:44$rwA_5E4FsґN<,$i(NL()UyCxe%e+c]N:8)#YIveL^C;Q>n8n^!#Y,M0%=:qoa{105٢-DݟiKLgG7>tQxܒ085mm*aHZUݝj':R̒}d19PO Q|]6rItxOjemt3Ab^k}imVڅqt-P\>PfZv:lGXi\{プ~S2M5T<V~tl[.u|n:qEVVH8U:}#ʟ768{0^y=\)텆Z9Nf?Fpau:rbrfrjM?'7*NSpvrnX߶sQ*:KrXsEZK?G|K"7Oi'Qou{Ewþ={rtfZT͝,T(ˇdM9˰Bb.'t_sT (~ m4)py6};"'L2 z@yWx3i9QY^|;'$gc[sZ\o +<`BCrnwzݺn_ȤžBG=MM8; +{Ui⧼x͢n(.vg-Q{qbwD' f9>LpS)|s>!Wc1*< +C>:͡)pcQӃF0eDtwNyj24hH?Ȋ WO]?5u\[UedXXssc1dZ˅DEoNclog7tnVx Vbb:FrT-z㰔:oaZ3>k6bk4y%qA/0sXI}ނH]A~»{DYx YgA~nmy/uQhw/klZ] +m +ݰ9zVQ{B܉՜_ߢBH%>Vlf=>sGgNmC;PdI7 w^fm.`W쵎mfPZHa/q+~5~џXvNm?67yx,mb0(1^7^:nkOǥ>&EZ$qcQ9E9qm#i9x6eǽ\#ӊ{\Wjpw]EXث2"Ƽ}8msj3c޶nqnMt*m6ٶQlk@v琇 oմ~aw#rsO>؃16{0'W+OPZl6'\a{n.`K7@UU↯.QSCş֣nCh»}={}0ҰvIcQ ^SɨcJ&?0NU]V Q64z<ۡ:@wyosoຫs˾;}@M\/D6~rq̛:u3+ޕ= rg8-zp{gb[|Ɩ\,>Gڴp)JP%*wS*lSvmfY˃'"o19=U3%M~,GrX0|"@2D{"L%՘h )4xL1.O|֢@09X| K!Ħ]4 zcɻ,= ^'8"\¢{p6 XɰyY߭M$Ю 8)ݧ4v}Jc)ݧ4v}Jc)ݧ4v}Jc))ݧ4v}Jc)ݧ4v}Jc)ݧ4vOEuK?/?ħDv?{Ƹ.0M2;GPO}]sw.b|n`KK?qmL%pM'h֓nA$e$XnLtc+t+J4L7&f2ܒnپ2n}~kLO'|K@t9Jϴly9]/ưNئ[(~]B.~vӼH=K lUsbMB_kk 䊵 m&YI|s-/ ; &,nbdIY,s9mf?gzEDB%P,.UFEc`w$t&!W Wϳ=TSs.a+Y36݋kt<@h!gtB0Vulϰ-L`!e& !xm`lB +"$:uv;îgq2וb8v7֖b1Dsap16ز.{Y퇕*Swfw[~<kvr݉V\3=n⏘96p}rm߼ogn"s{0d;2@ї=@ZJu + p5fMZb\HZ:sw@2d +(<: ^X)zw+7U{g2$?C0 |7W}nZg8ygJ=|PϾ-tiohM8g6]mt(E=&gS;arnM`F,薝p׏Q͟Uo˰R~hו~] +_![wfѿ'oYTp]jФǶ,{dljZmnÝ{QķVD<>FgVGUЇ~vu5칫M1̧;<5Q̇'5s7eSо~2Cp% ΍|\g8gZPMVC%[O:Ch^;\/_>|~oܠ+}Sگs8ֈr ?D:l{\7]YVwQ0}]+h@}x;aNf&΄syn҇''A7ITM8D,>|$JcRB +7)w} [d1)).J 6܄[sKƲs \f ?`~d0({BsA܄.ZYRfOK..mijE.rs;ýMT*{ˑsRdMR;~Oߩ=v'|ýqȲ?)pQ#юe)8isS嚜(m?.: Ƀ(Zz4Q8qDc[}YS"=rue0 A=:%>fc>dcNP` ;T7Gj5t:1:{lJ@?'fτF/m'_ʓ?εCv/ߞ^Cþᝂe͎q9.jytUoyaǼ@Gflfj?Wf@>~{[_3rmi1IHUپ-n"7vcO<*vlgJ!%~uH$=ԍv bACZtg/=r*xS;>o>_ѳs^ ?_^}7/kq>}߶o}~zſc>/?/ ;~oP@x%mI[.2^.<3-L:`b  _xi#R?1?t9p3pUZ 5P0W*TbhseEHeXAOT +8/[}P#rpnz3[FpN*( *G|[ ZlCC-=hd̼y { + +Ѭ~d\XzG=;5u)S!"$s\ _X'Đ! a@3',`tzA[ϕѾـ'cn \v[rHLy܋9yK<և 6L$ ߓCsoa/R(6]?1i?d/ChZd ɺa"` F26aYIԊ$^`T +[04QkKj "h䱒S/D1O$cbb3ꁉa8,5Ϟ"fy!lNpAw:hLXFe++wW}Y nL"N)'&߈!W?ÓWIv $Р?C!R-UA(d'A(d$J )./BP!ivK*5!|E&14Sgo_4FEg[`9A⸋J=V!6,vuVc$1F9NmqT(K>|L TkߪP+pWvS 2QC*{]ȚoMj]\a:e8kL_)Cv$, +Vb6GE| RAܦ+`,P@`l 1G#l)i3:eY&ƏJgo`9u]g9= WA\!4ֲP-RC%UJJ<\':u٣IqW1՞ڈnQx)+h+ d2aVh lɊRG5#ս [dA/ JiT =5JzJV(+n!E9` guq2BLdUoEia g9ل6mfk*K\)ceU3\FX̲ +L :DzK2YGJOӊz2ow%?U@>uq_J'VXDxXV B}TEЮbN0P#!Zٷ4IalGVd-S)Kl?j!J., + h&7贾ȣ4>\:{^ ԧP,ht>%DәB˗$%D+m$wVfpgԐA!"CnJvBX\ޗ.~LHx$ +TjoM-B=yÃba@*(=& +{=tͽ%hKMjªdiO1 O*ϣY1M`"$W aߠۻto9rѨ6 VEH!i4Ti9gʃHɵ&U!8jܝ"n  ̟ Ķ&HxmOS#*CEY0]蜘J̸DE{ u=PuY=+ n*9h:z64+R ,B{<\͂`I2L J1 ==4Mxٝ +2Κn+d"2g ;Mi!i5"<ܔ2 +q&GrIr"x +FFχ)|i-eIT:ftuh M,_M! !.HH3a_w 3wB +&S;t &{kQ2lQihv,/AJjw r^“"F? E 4 l~RG<4Q\ߡA#zhGi4rTo Tu$˾ +구J YE +75bDs{}ICM|XcM")*XY%tn>L *f${/ćTZQAJ=<K^|3>D9o +fFLb-:5U, +@ZYFU$j4Iq$ENL}6JX }ٺaE ;9K>rv(wLDPX/oW<3XG""yCF2aZn1BKs dMZ8f໩t|F0ι3 (8$HOlTl?O=5$K!N0eTfԠ+[ RexTmը)9_6 Ш FA{}P eu8_^v A?d}UwP;E)ޓqNփl"aCH0ٽAyPoCMx0A\VB2Ɂ7FT}a/6#]($#뇭@!z+nL4CфS &ō1GG "zТ}Q `+[2<0Yx'i\e^EZ42yR*H)4ىJ,]^)I؏tzb$(mAPV;6H#ߔ ؙ1 +f~e"go%Kq4gmI^01Z9tæP= +Gq0uL Pl[ߴ$Rsd泼Ww:jĝ|5R(kA!Rel[h>O2[S5݂'fRU:y @eBUcIIkD듧;٫aʭ=0j4oNְLzВDq@Ћ2РDj@#E 9O{bN:a|R*]rk1Cc +'cpZ J)+D+u ZX"(F:dE0ҌD20 -Z<91rH˦ ;[$*ewRT"1D4!44Sڝ + ɢ @o9!bC"En$ /fE-!VYHY݈yWZ@"J(^aX'MZ)ݷpmXsZs(X&K^Is&Ӎ^$0-@Scnַ8ljK A˞އ6V,|kڰ Y!2pb1oi-@?,_ae<1K/ UIK$CM%x" 3\j\S龅yHUwmE0:RT 5NAx(vZԆ-t_m+„3yjQ:PXU*AW!nF]PUHU +57l>EBl64+̥jQqp$ qR?bZ[? [*xh- Eo8ɼz<㤱-!<֕ ]K +L#Sm%p4;JdN3 #vm\wؖ2\|;YT\rfGPh86yxt16(u.ViA *Z?l<)L J J j GI/[P'E͂a,VMBhTer745d!I DS.( k*hPSZLf\,Ŵ$fz +KDVL 2V oI݋QDLOWZɪ͙&XEYiJItFYp-&yl!*UNCɒ y AXJ4"uOHšC3w_/ˑ0[:EFm*#z!IΉ;Ix&YQ9P +%яmI=^h&L ' d:qtׂFlZ]%9?DR`\)|ohhcɭ횽H:t!ZKȶB 6]-A LObc:xpGp6:Q6vB7]MmCk7D@*4%qcW=T؍WnZ:Z(( +60{V=` ZLi-L.22lPD uQe L2w-@ + ϴKB0}, OևhU5Jz +G[f9U&؂pд)}ү`v6W6V;قB$īM +L66x_AFW7(WR׌/LrfDLn݁XDG1A/X [N)ISTA32]YC?KK.XܰHeN3՝dcPbv@ Ih'BȰ iK5RR +Q5@a[iZјHͯPAO+A(hNP.xu JlCZsUY/o },E!ZblTB3\|Ѵ8=HErc ,BW]ۦ'Ua; ZRҭ7'a! +0J小4  ewB̭ R}N-A})+$B|TAet!3QDwmr 9n"!f#5 +\v ${BLL߰,Y+Y)]14[ Z؂[-)- h \Nӱ/0Q*Q dÊEip$p-Gq- :YvSY\:b~-hF}I ͝Xvh-`$2\fbL4er(Mqm(Y+aHy r-y4~ž$:AbdlT0A ibtM+&Js:{en*d "dTǰzi;/z0)ZC* +1jLMY mtlhI䁹*lƒ_蒟(4 r>M>[ђ&̠@=`ѹ)F.\n"d5U3ЊȨ0ا|d5Md!gBfDZ-ZǦl-F>Qo"T4z<(Iz` +Cm +LKu4HO(3w?JKH䂄VH2jqDНa=$p Pt9PG*Rh̘1SsS +@ȥMDUXg\ |%pYDpf7 Z`KЧ^I XA+h˳ +dD]a@ӖKX'ow, b3:x:vdf, 0Ї.!S:h:Á|bĵL[J&RNY +g^Jge]*Q@+E֊sV`@d 7]%lцCeSj)5VF19Eh0Y-0T@-8l{4H o򊦁,k4AK 2;zXOEYĪ>)5+D!ɌʠL- {fSt~A m.j6Oh:2J{6hì~K0)|0x\29,LEiʼ;^{o]b+ +DCHJ%&~GsQڥB̽ơ590\fH2 [ +4DC9qL,j1L*w퀵3VT٪зXb &QѣCw[0BŅ,tTyQ4Y(6WΡHG=ѫQ bFf>7C?ś>wSdJ3>j yy%l:LQe3fƾz*||4% &ah2U4HV!B>n!pp Qj`j)ŷ{eVF)7*sɘ +`̀`,'Ã-1T~G%ԛ\0~-AP2s<}mg&s)t(5Ke -!JJ?|9UCtAL!2Z&.!j~41! +ݢnͣ-T J[* kfKAMpUaO9^#Ԉyz651Eۣ)n:bØJ&gSShFE`j6i U$ D8ZO)?K D/ +mPHUrK4e-X!- FhBۧ;B`X)޴[u,(Y),Ըfyr+S5Aa$+'F^gyTI{ fW J;NLb,Q.UT& T*jč{+Τ +EQ8H_\,H +e#EJs8}Ka\eB=Lz?Fgi2*-KPZAfMT+" s E Pd2 3L`add0"uc<=X@@"-6ID 378P,.+dKOdI=euY t0Be I/~'< Hyyz O[X6|lY`j4W.O ,Y Y`ręL 1 k*̯UJf)J:QJ!\6yq$1QI?I ;Tek'LBMhG'kYz䁕U7W!>Z + +nNqRH9Mw2|=,AX³(w/kٚjksMA[ۤ| I! L_V:[6K#E0:Q!+ +^e`R,i!x=䜲:Gb /. ,D4&^ +f" pٛ0 Hfa(hpJ 3}LfBbs4f:B :[DdRdJB#XR|b $`@ +ALʼuWJ)!ƈV(%*U: +z] PpnT:"*9D FbU0j;&䈡EPD08U(x`3 :l (+ MNm]Jb#LR1X27%1Ԃ8QrOF@SѨF95&j,L2 (OJaVVb\w23]tiT/2Z4̵3'ˆA8Qew!Sеԇ0ceV0o"زJa^KE$'B`=HX'eQuT=hYLHsdrn +`Z4FN< c+3;j*|ك.C`h rgh;cm+=>6ɢ=[ўhbT + E.D%T;1:c5 r3aV9' bK)rt;h*S,2 B35 ɒ2Eh9Z(d0:3(x)**0!B3[`Dh:0hLXTmE*tPAJGA4d+"Wz7"VhfmI1Mw<I@G r@BC{8HAZftfҫrEѣvR;e"Ymθb4[nE)-SrC&WbJD,"tW(FhBv'N7e$Vм*1 [HK!$l +(NT'?2`,dQ(cq͟\vd,9k|LQݸVCA-Q #ݷN =Wpjan/GT ,VPbEa([%4ȶv U; @!kl3ddEd ( GDbک$ +^ +ltP/Ng"NUrBE~2 QO(*Z"&ň+7bhP[ T4epf#գ)ky1Hl 5Ć(cRI v\kX` Q42Iu{d_mbztKpV +4ELAܥruXFSmR3IJBA(S~_0=(?7ɵ*ȈElaVHmKP/F>;1Ɋ6? %`9*L6GmRBdz`a *\y򒒞Y +Fڔ5󨏞^3tKҡwfn 60W1ӛ,$Fg%RD?* @LK82(ˉLGJKB@[|2h `MfhƑ2,$7s1IY xKaBS89 9r1SIBXeJO# 6osdZif +B%"Un:kHDyG  +ZDSs|O8kveA"2cLS<1-$~=,;h +6fJAIQSzZS#)ǐR,Sb|(M[@F%x`^LT%$C(F 8%y ^YT,;7fenoRœffVFiQ7h!WD)ڂ&dwvpdc尡DBx mPAэ::(2! )J1u@tܘ6*S5Ր_,!2D$Ԍ)JnQzP DUPk +s2Z-%r4n"(mĂd#2M(L* pkrJN~i#@lZ|yC&dQ^@p ^ZPҧD驴NQ#NA0Ae 5y;,DRĊ:6:aO/RXVΐX +LKw(UdTA?J BAH*=[(dz .4śIg>C enGiUUfZ +ô]U+#1cp`a NVLV.Ũ~-s7s +b?$\X##6(o[J?# +I3j?9'@Gf\TJ72&[SAQ5 I'shUAFסwK[eɠ{4JSk32*P+Y'LC-JB3UN6EY3qPC$XDJ i,N^f=ݖ!_KyJt*iaRS`AHigBPtyfɿg{X>ҍ")56m4ӃR#؆8\rQ;1[@eJC$m`T ѫh~Xee9P5y>7 y1߬ϊ[< r14 +1:zŋTtͱ,rJ"}A{YݝH5}+J,B OnZmu|Ғ}ͮ,9v}31`Æ'FFu`r[,2*v zE=UVZ~5iH+ Ƙ (&VB}N z!\X"=mCS M1WMSk@=EUh]pixN[2_,J}erkWb3|#662Cx[f`/dֻBЂ${{{\ ]m#=4.>?N#윖4l=Gl +dv_BV6pӣŵdHM6O(>vQ@ZBqFm"j(H-~nm-Tn871c + uDBL4i3츟{0Pn֜*ͼ %HO79!ei #~@<&o^fnxPCVt@4yq*;z648໤9O=F :Ty=r ?OQ|jd;Mu1_h&5 BXjln; bYIN78AwYe\9r4 rr@,e "|&TĿim22uGmُtbfBQP|.q^K*@r;R{W)=F(#oNەOEjJ PgyЩxXs(TQ1pvLU59ER#jUv ]<}-7dI2␼GO>hgU]Y +^wxtZ؄$/$9CIw2A/t=zObWG 0 ܸ̬Tqoaa< ץ` y-_j+eQSb n=q$Q0L\IRE ";A @( F`:.KJ'Uٰwk5vy5S,x 0O*׬ri݄q{_N58$cw$]" F%{ܘ8=.\q8^cg"󞠅#Rd.YCT'zoPp=᭪!n]'E$J.Z@DzvEㄷ dBarzBJVId {uF޾yGPPR5rb u5,l-S<7<ClZr{#,80M1Y]`6J{zJ +ײ~=_4P9jY]e^͸>Atr.? [pcbq9!)QǟYDi5KjPC2tѹWޞ}P7Tyk/z +R5| +T!?GCZލE愠K豰GDw 0h6 JH [b.+䮨rKo&>"5AC|>9( Fu/ ,Y +r +%Wz%>SJ7xZr=֊8h!5jdP13Pb`&UR8 9Fkn+SCg\HHJ$D& +C^u{/c.,m9V+A(Q|e5*Ҷ^,{Zj9${?q"9jkthwErS<0NMt}OjKŲ$$m_*F +UK],BP?b l1;U,?I%H/U_>/ƒ6XEkE7Rz‡uXaf+BYozV&)=YͲWnTa c]ew,qD/Uڌ~XVxAy@۩)={=.h.kMx&[j "h;Lߘs&N+@A07x?ԢrN$AIU[oo*0h/Azt rA*gF̪%X 0fA&!'m?FđIcCgB2p%8!cr>ӗYibAe 3za\_j>Or3!T0;Jr D/_A"PI.,bJuEhY* ZzjgDOu]hΗ"=F{m mk:ؼb(c`^<:iriDZB!8{x-L9(fsNTX>PH/:ǁo{ϝ:0:E場-,=\ߥ\^Jݗr0W +1)cnu Ӗq0'ӲGy ;#Lܔ:?cAIBN$xШr Ql(3 OZ=N&pH{h2.ʪyc3 6I!Nk$XD1U AcW YAidH)1eQ{5^""d5P/ծr㨉@v!I. +!i yZvȞ$=i 6,:SuI4W<K:gڸ'1H&墇-qϺ~TQE; =A(A8/zٓ-2/Ss/%zHaR-{S/,`76; a3^V0\_Q|"387bt4sBoXM:n0*n 0LwYV%Iz]m=)7o*i:ݭmIm5)J.[Br! |uxe ?K +|W*^ެՎK{Vc%!U` %}ssNAcBD[|=jhU:FH|ڄZ~3`tbpSLu\n`nHBF<}w*'aq$U [PWً߆юWS[ڍĺR^5b;L{≕98d8&94:<ȁƃZ|dNWٗ+$bD(ؑT(gX1ǷQz.0D>7C.}n&(,Huehv(3ȏ xZ>0LV +2%ρZ0%O6w_l}:=s<5+Hg)\5a' )PF)p30l:{0pykc5g5:X}BZklÐvp%VpJFrZsUK`tm8=dT:ydKpуG%jT2C"4;ehbgc' $E!b%;Fdr Ǵd^9 dR[~BЎs>U$K/ yDH^v* nX0sw _W[aUѐ+D,q'ɷ4TdM*(H%݆pr>C /Y'M~Co PU"n(W?SWؓ[CK'c*_+xԬdm#7B*(B+. 2 lW/AZ9W,a1Ϳ?o,wݟǟ? q?~?Ou_wħ213&oD^G!mO._q$^T;o D=!jA +.|3l`8MQDh0h?p"PM2){=w~Fώ =PE =P@3L>!Fpq|JVUzCa!1w{d*Bѣ5D7oO3)ѣP5?C@,(C}OBDY#O⼐Ta2i`CU +c&;r ^14KjXȕx:_p pOXp4có+0t;y:!HPF0e@ +pGy4Rv1iϊsbmJFyv;Ǒ_^I~S!`3tfRdΞaݥSPU]FD32gR8f~@YnC:^Ivny# '%EΒT+2_W$3y#=4{Sƌt.JR@M,B>Ս@2nW9Fh@NlT*1-@RΫinvl7(D?J( J0vU{~㫑 WN 7z{FBt0O"v=Pcj=XmPxF.*w!ߚaSA/* +3>٪;q1΁xH/ 4e:xZ2Wlh=uzIOۘ Ce=D>;Ҩv{(b .yLPMaX3@K.A{8&ERcjm{18r]LGFE0_3uU+ogQ}"rtϲ e-G˃H#Z;5z~%hC9R.jM)!25cv, i1pQ1,膎]/s'Dݽ -s&L<)!wmPq͚`Sdh- +d%xj һǐ.&rQ^tV#a;J\(';0vP yչOϬjV*`O8N\d=_<0 ¾=%} =.l$id`Kțz0G)B%O̔BH9h;dg@!kaq6>t_z~:NE=50Y&J:5dVz+aIIAaC%4vh67ڍVB@i3 3=(ˢftb,.*@Vd16G- d9=חo:=rHWgĀ>p+X ј |]%4(~/_FY4RAFLtfY< y8| Tv$P'6O~ +07P8 #.o0uЭE\s2|$dE2[v|l>օύ:Wh Cb] &SWVIO|4$tTqomM^{p]C @(emQa Yl}m6xzHǷ{Dϑ`Kɻ3O|I>jR&晜$wVYdd_DK`PTo,a:a :s o jO[F~7GZ2ŎvaEKBlEiE<tWTt> XY«:9'=k%˰;x'%dз2, qˆD?{!J/ewJRI3ew˼;`a:3l^aq>i@jI{Ba@* #DM@GThŁ[ +br$L/T0з&7UKYۛ#P@&8ZN:A/C{K2u5 Cҥ-Nу0!N8OFK4.wc%oWn\qߪބ{#˒:ab sUMnt= 11v{gD|a& +2{+K穔 6iy-…,$=b}?.BQ\EHS1,0 ݓf-J3؅ܐc154P!>C0؋K XMJK[Jټz\riƌܻڟҮCL(f^{!." +˂ʽ2,T(M 9l93E( W8p}|]}?}~tw,ئ,Q -ڠ-7Cv|rI9S؃gr7PAo Trܸ9SIB#bt*XNqU>.`VE[ 0%fR!ݲeoF)VTQsJxYoxBa\^]#')`#p4ˤ/ Td]|岶Zjw[)Hai,{}/zOnCܹQ$÷2hH.`K*ŪhhZi](I&'05 _S#kah3.Mj?ksxQO&(ܨ6 XOW)1/,vES0R{5\Jԡx}kjcĪK]5N#h5YS5/JpRdSٍ l[·=̓owV>{p~SK/R:s..l=qt(Ku틍A!w}!m"RGU5N83臚o<(TF}o[I"\ctSu[bF1ף! +i5MǠ[* ?! y7pějlzDo +A)ºMf^-t}"~K3<!'G q>uj8d,&e%CmLD!ꯆ_nw&@s +/s#0:C![Ts8W`@FszvS5͛v8(0$PH(+ b4׋ِ] Xk m@P/{?;18=3lWAiC"{P-8:D6HbZb W +H`eڱEEnqeҬ*"#k7s eI6'LH8\{#g6|>LT+B+@'G1vP<!c,A%z0U8:q@ *;)_L.-46d_ÖxxvTzVMH9@q7pJnŅϸ0 aKW2ͧk!ŖX8l +vL!x@{ApcI|`N_sjh`#oiC 0s{ U`V<)HYF&졃0:l:z;0sIG %u@jdh'LQnKCMd(mjͽ&QǸ( +DEC۫z*i0q֞6d;2 +".oE !kgn/`}E[CfBq&2Mn .QjC)8a;;(!TP\+:@H)~#f1>m1:\۾pĘdjZ!/6D:,dy jd:fR M[/H&5Ԃl x?'9xLqD췊QGCLֿ!#aE&l\AQ-M)Hqs5Dڜ#@yCz %qw +H6W`:g*ah&u&c8ƈ F8 +2Pؾٮ_eTO[0S`%OlO]ov2$ |5ܼ @'C`sCzHL~,yKh*P +_Gn.:u؟E5-gyȲ _u bJT7r"+7;﷯s:;%lԹ!#],{rq5.N +ޟgqeٹ#q&}r5l}OZD<40 Za4T!BHuR# g!ƌ۳c7QD-Ht !G|u<>SJ@B`7ל (Sx `Ul! +ܸL5oQ dm2d9ZMgeNڞɳQC @z8tGu>"\M49Hv {V^$1uZ&)f07^<_BH's +7!gQ&HD +r&%d?,gWm̈GbG܋=DY}(X)帜*| -M!;Ҟڗ=XSc\˅4n_~UrW_F{ѣS#.eF&{CX(^\Ec (E]|gn ҕ#PAijs!9{Ae#̋ DBLkgXY,*eHe#5/sCX"!_:|^q# +2ć^aG} i9Ўd?2 +hz6B4x㹬J@wWوpOaaS D>z*hSjw\C1}*!( { 8a@9O$uDDb[0$gCxj9_`M㪱}j!H! +feu3T+zqE=&B"IՂYoBb.fFZ;9/ÖW!uG0ۀN¢J2)XP%DU6>k^hQx!yөE(H88wk9Pڝ(!Zpp(Iz&m2VNS"mP1b$唦*T@UN$@̏@sW A.. &:F6վ/= u9?HV17!E߫ftz!Y+|ֿ"fWEs- pu1)R򼰏S!rQpdO+ϾQ Q=d;`PJ-b5!ASAJ^Y3O$·UVy~Ys0MZN:t-{;qs>0ATӒL%m&y5b'xgS`Hv3:2fXPe .jKWukH*ξ\AfD\"=36Չ-˙ނY$︃6uR{9_q43ڏښ]Fw<U,'',ϰ6YC)u +Y/lʡ zVqfҰЃ=(B#+PRux(NQ%1(=^Sv(cĠGűZ EЫx'bGcNcnVӎdX?3VHXbb3SOfdXRX#2Sβ[~ۀ@65`cɉDs3AkY\H5Q(hxt?t ˋm!9 :z%uӗ%d5u#R-|T#fe4DL/9[z ӫz;<EɌҢ=ĚDG ~f:ez1t@aO_yq +zXc {JgAYHܷ>^|4@lŨ#_k8pS{L(tpd?AXzSңe5V$}a!7 Y9QoC={Ft C2Ƭi?{/ )A$]1Cf7ϛ` u׸v`kóBay3F܀% URL<tzd~q`X/+^ʀ!UGVǜ+ͺ@)ᚮjgQGz!j)tkp> v';Yp"L RkϺ~Umԙ.8pjBʤ;= @ _MRCH^G=d;VG}= 0᜽H^ 4^ zBo34iD!havXxU+UZ!q1Wmx{FMW`̜)$ S;PʡU!h͗v=^t &86>t==ӂGoEǚI_^x!]َ(ԣgu LЀ[ǎKG&9t7 L#z8?uz4ڽm= | B6 +cQЃLA)TFG'w_ [l={9JޢQ=ʓLIT7f)2S.E_?U]C{-$(O7lf 6GSyy (;(od^NT*{U*nd̨*FU<J(45E 09YO1d%!$TAXٳVfΏhmrKs*UIQDדH5 Q-NT:%.h7!J^O1o_'_хxojFp}KQf&IJ$ZɃ- +rתpPoY/pgNB̒>``BuO%̎bUDdz'q3D!*8 +h4R.Ac*:z2<ecYO`Ȑߗ=4Č{yPKXe:O$jشAB_Ý6*BdGS_Eh JRl튎[yϠ^#X@ P<~?r䥆eTݢjH@Y)6vV-B" ywCht& 6D0{Oδ5t :p2q, i}>*O_`U&஠XPjJ]nUh>5r0}eA6h?rF {3ϰx ;&RmC2nC ˘6" & BKEq,E6⹰vE= #=`ZHb9ףpAVʋc6~s_Ur{ =Cmٓn9o~ XP0EBQO1*P;gGs'΄Y[@ywL7qXWp*Ha Ir@Cr.H +=}بb0̐ĐmC8 +,Y:`]Y׎6HBjS@5`!Y]x,7"2H +@Frw?AA j7N{ڕ@"r`Oz %ekㄗjK?':< xYESJ"i彄ʱRAIg.u3\tP4Ƥ +L侅ov" 642=6P+G $IFyh  Ȏ]}Ӳ8Ou:fd=JT*H#ddӨqUFK|8[{Y)}{9X255jx?fC)i}8vJ}IJ8u_F.0sh(pQoE`>6N8qW^ApatS'E*dE φy7IӥAiC~SmnG`fxAƚ)K O\3v* SYѨ[R)"ziА^LhBBD޲$a4<N,fj(h'QV@eCdXQ@rRYˑ] $$fOG+y8:䮇):6EjC& yPkq-aY;<_=^f[!{fY` e㛂Et+CW{<ٿ =x8kq{mICV2@ZQ} VJG40D;a}oxN(X=8FH99R30V7 W6Ep7%mTIt`CǩA~sL\#ðV`:1w@?.OX$Cpt8 &$(4uGs =/ }SVTcdo CWޅp}V+/h.wÜE> c(_'aɞe"Ge/LabV1ok e,5 nAx4I(M( .g+c ‘X%\?$VBā:|9dz&lfnI^gFID3JQvLy1mUO0ڃ^n#.{َ{CO'ia4?5kSS.A#FV7oj^ +\ZXèf;(FX$ iC7eT9HRR* ˛]8LnQF-nR$ }+RrX%:yA2:B o PJ^P(Q#j%{(fb;KSzU֛pqg +ɬJSC +ߩiVȋUW&؎z%1:HH"),H]-B}R $%x2)HVew)#I-eaE-R7.GXƻy(w +/O-cs$oà-w`cׅxCcivoCcBēIIraۡe$r2ee.8C:tlz!,ϙC7WP'$<2["dGrׂƙ2ɦIe0݇r +(@/d(}a\zieW.s;#\֌@!Q9G"F ̜(LSP7ReoS'yzd\0d @] '6Gw$=H X=)W4IaZB,ɑQe JGm8HY0eCXsЩx{}2fHQ&hpD?-Py#04'Vjw#>~!]:$ YW&iфu/ʌr}T2)$rI/9w>޷Q$`J_O'ыoβ[,Z5[^^!K4$X 0t[a+Xl:5IJ[[Iɴ_+~4le!+rI 1M\VD!{@a 6uQPM1F6O38@]ov%. +hWU!*[nr{2 +[ +䫒)U^bg*;ZVP8 Nf&Q)XAJZ,'z ȶI`.%P+{}eyXƃեl` P 2TԔI=EݯMsF_f:3)0kĭ +m@iOC7!3" 6<'6y0ӟQ& 0xPP\?w[ f S.* : 6 3kքyLM!Crb@6 gBV4HEcCEI5Q.%d팥qFGQVoV2+r}d,{O=weO4GCpϫY==a C# + b@|S%Pt=,cJÀx~A=E:h.Mb%lUe% !QGTf;xgA)PP'ż(*Vpn ڼ"}ղl"蹲|9л/ǟ V'qi=U{Esm H97s u$=q_^L;RAIŽu2\LgO!@SA(A$%%VqMQ4溂(z6=2ZBO"rO$)  <1*f XVg 6p51%:]8L%j1ykttFV <U/[ YG^ (Zp; +@JY Q@@wg'fHh|r ?j 07B>g3D?Cƺ%IQ =,'\p8gBE~ awuaHKMv@~BzpS[ +[寉~_y`dP^ GI,$Y&0=7;N7Xhql - Yk }%R{"dYl'o:k\)]\6* +KYO6.$>Y|4PUTsRp[#V{)mDmٯ're.\P9A[$G-I~hr!îfbpk pbdYV`C Vg\TuMAB8t%y1|_z\HC뒲?  >f'lmC%T>ۘ*k s!ƽ\Տ/|2t9rxoha9kb` L@v?kK:F86z}2bږ×EC$-ס1!ڒ`+p->b{Hm~h$@VQD~~IxvEQȏ"d`=>OQ |j=^ZCXpl^CyFZC`hX[ï& V$)2 GU-AiZ x2a14J$P7yV4F4A~n SBr T.|7Um993s0E9;\6uസK10IFS7kd:#pbg9}m?|-ڮ4z*-/ِ;ɡ7*W2Ӄفɳ8dQ=j&PTje_=vAHkDB)ySܳgBDH)jT: ?"'U&\z`m ;8I8é"}WCpob=2`]R-X\DXg[RҴ}w ĕ A&NI%L"!X!g iF:̠B,3 +qK6{5޻&:EsP rW(*%:T@P>D鄋YV:+B|21RM=HlUƫr L><%IV"c#q|Et,9,{=dTC&yeR8AD dV&bԍ34yfK7j+{TYP!@fM`&+x̀ +[,>?  \;>NF!iif3/sO%Qpue"=(eQoGQ{rRR@@~q 6w L}FN*7Q HA'+G "<ɘ3v LW 2U'If3>@BJXޑE"?,<ѩTm0ze0raOʲ?/2TF(r4!Tj+7٧0؜;RlA=G [jV/^esfӑilG%F ~x"80 +DQʖ$d׼5iKy`.F`=xW/aD<˷*!&г꺂mApV,ֆb5CV_^ {{A3a7(ְ:@,TN!EJL'(a +DY?]F$)it= ^Zs2|كvwƟg$ y:5{S$iG=tdJ}K +{][_~U~T{M"eͣ%ձ5`[kRl^acC{Gx\5zHy|g#>NK#;8|LˁLWEpm(1cYPWPE堥3%T;z8t\G+ojK-v1wأ}" ! 8.s/搇W=ZH׷Nإ) `"a7y)@1TςK &Ӵ 8P˩/=~*f01W9"zt į QM8E谗:Q(y 5FkȧEKզF:l<v[Pirb9=׈[AXFt|(y&7z<"[(s3}Y)VqX|!r_Z`vPC {G6>Ҹljb[NY05WZ03ͥM +j'ՓLiH-X꬧Q ȬR:d@vH䕌|g]ڽ*)_.oŒkHf(,YrXr@d+ap/8^~ aA%T!&s[:ܾ;)! 92p\F"U3)SO +5I%Eki_|] 1 QdCʠl\#KTAM +lN8ق$tza?zӣwDTy"osI"W" Ր +?zƽe&AU' ,Ao%5Ҍ!l)KB*mCëZЯ-D(n+ƶ*cDX6;Uz>^q'<` `L\ L}q2X{%fG?wc5m_^_ rUy1BHF*Re\*XH@u):NH5"98ZkĂÒuxSTPb ԧ|3ʤ%Clٔz)Vɜœ^WѼ2s3pBŵJ6db}Os ! +Hi:N %i@: $ZBhلhm"< ; ~4;#9> $eVBN -S,%G ǷS*M0a Goae㰱y*7\R9JovV@|ē xo}vuھASUc+bC@'oZ9oߒ+5u?!AK̓7Êړ~;d5 3$ 6`'ݮUu[aQ)&U"s *``x}G#M\>5q` xbZ B|^Ž?釛+B5m- 'k(MkΚcLH Xn1GXA䢘ƽ5¾i;Sxs'{Tbb Tp~#n܉+޾3󤆙R!taUN:F~qY-.kOnA$[l ?},7{IA3_^H?؝Dv[Vيٸu'ġz92 ETr%5L[` hUs@e[H*եAA`{PD=Q=lcƍIno`I|w zķ0W]DhRK̂!X>W0Ǽ~32>5H IěsC;ܣ8bR҂q/v ǁ(i@.#o#jN˄N Sfp Ed1!bTvCJ'**tANxUiBR M[|Ŝ5ZP +񙒙3׉=,0;yL?aORQ>Seiܣ;r2{=o泂;@1diȳ6I@Rdl?Y!V;]Pq֚ wD%*|Ȅ!GqRդ"V3?{*#o@J3ԡQG @FH!K[ceuo*q +71cXvY ;$U< Nj  +nJϐ︒x{ ͨf-[ +ήZpr)KЕxpQ.'9E apaACGWbe LQ]gb Dʤq )i$ԇQ˚ab_}'A_7$I`$؛Hc*:گCo%21+| +c;3`ف#w`z8"֥lcSSK1EèU+W-GyWW>6YgŒN631PM3N %Il_enѮ{^yfI)}`#qP/ +YQTJ$>c^-#I#x-7<),<_ց+x1ʲ,!|c6ee-ڭ~Itf +Lx96:[ ζ6tC^8;fWfz%jpБq\q vTk9H?\g9S +C! {u %a.2/$?w(F4aoDk`-YOVT 5Dž]4{->=>5}l :@[VVg,;Ъ̵hEN6vy>@ڸbXXIoKf(6q")a`]`8(]c%ڃ\Cx HAH*T l +q NlXtk'@XN@ +wbqt)p[$![T^`Gl`%C NA=B=:| 5^>,q3%;oRH`wʾ]+Z!;L9Z&LK*t(Z<'5݉M&7F P*Z ,rL#@awƤ%q/ +h'LsƉ(܋⍤9 +ώ!ZPK1Hf`[}x>SMp~k+t!6F%fG?J:ZB )1U@^rO-&/MaЈ% +x"JjByYfT VI#fjEPyQSU=mɈQC؎1?WL@¢®bÚVU,BaK=,| [x$Q$S0k}>̆BNY~qx`$'U.掅_5&sRK8 -}^ ^&?mT7|ur3ki:#{; Y9Z!$)'Zֳ(nQywdYߊݐyI4MÕx7x6PhhbF7˫|:>74,YWBt!'3( 5`b-^/ȼ-|4_Qe@uѲQGl*sQolVV ^FXKWܫ!~#!ýBs\(bj{k)R|jDbY3nF`}GhlљM@on?5*+c] +rx+ NGSqvB +:S->&XpAk4_h/oDURс!yj .s@3Ee ͞Tykj :& 3O3;j?:g![ɼ s# .L(k×i |׽tVX S4'VG>i(|)&hbk1}4ꡍ~nsnx8Պ%/w np#uEa!q/'dUch  k/FKuxFuO\Z5>s9y/a5l*]=Z^g7biOm~5rE%_ǷW{߅R-s#7ѐgÍ"P#RޭPK7Ձo-)fճ8ifRz_( +'0Y9!J]7H2ֶ~n +wM~aLM6{z`;fH~4G#GvS +>ݻmoUH{dO98=w*kȞ7nd-z<+ÙC8?r. <`&{Ci-hޕXia17؈t6 +K! /^1ֶ=&1}~r+p&)qR3C%XJgEY-4If<&XQP0g(xFv+aLYI*AՎ0y<"di)6+4]l!3fsWut8RYw%OD 99,7RL(E E!T8aCHRD!0bj9{Ԓcnd^i~IzܐL} P 81lJp^1\&Xg=Z6ltSp}ua'~ÎnI/~W(Wwu)0O9IA.jH|q7梗L#|m,2;c$->h(VkШ-#ʑQ}>7,yufa? MF!NݾfZ5jČ/n-R̭-r{WyD+k}<8hs=ȸM<\$Sv0^s x>@:4:DF9G@mwT +#bMNS3p{roLp&.Zm(U-l#_ݡ f__hpϽ^0LO6O;Hh O]]%=t9C5 g#ⱷT (Sml0 rV! q a|"Ah Qêe:q7kpF)joEtERmhxg=F@u +1e"b3,rO$62Fl,\9=A +JPFBŜ5#D>\VCwCTH5z Z(A wٕ@pHЌC)(A&RЀ*}o}}g +Nq6N'忠= Ԧ]Y6M* +Gq}WKD\?GL>"3D pTU)P8Bl}\YIe 3`.Ȏf;?v#N_g ^&{8۫ĿSC5Iw SvMۑg]Do+i^GD .1o8uJ?oSXu%5{YuFku{;-m \}FO)AwLVAVN\?v&5l$"Э#);R\7]lEmcJPDb?!7ӝL{ZGj]Lqcm"rnF0B#fvp%=Es>IU[0In8o +E 1ĎOz3 *4K9LP_xʶg*ѓDYhuLPw`k)\ _0G`HœEve58Aԕ<H`T{q25@V+t.Q!`B[{`:,p _ZG2Q T:e@v* FڼX/hI:=n₹z32a5n UWj0yqPpٷQ2׈,*Gc%@Fv2s!W9sEIl_ JVz>YY?QkHK2bsv:=zs3ȕE!?qoptIҟAQ&ЏvUSm}zK2#|6v:6SYqVxN}ޡ:n9BKyJoTꝬnFǟd)ڑϽ6AWWӣVP@ +oN_`&QT5 QZ[ƹfc*@hμ] "5*1ouT{h DasHpC3VN!<[gbRo 9AACza;#J^F7FI^|-x ɕU\F؟0>YJke*G5`Ԉʕ@`]VAks`;JD"e?WIIdƇ"]E m cT~uz"gj[!߂Cz +@6Ef2c^7IוPAP!(5E\[ H_1Vt)t*.J +^;}\:ꢁ{h#9쳤v/+1cZ[v*z޼+{О.vȆw;ozcw`JiH烈OGCj&TQː +F8濶MTen{/tDR#gSkH"$V?[% P[Gcv_Jt_p@N./Xڝ=^*A<"vtQt7#0 ndzf3}q${cPC=Ν(mfW&W=)aI5cMiY_vz +2L|e)W}hJ X*Fu e_?r ևAPGp=09 +W=EVҢYp&T@t^]7GżRəkuIL::O@݉,@ߛ?M^$|G@̠ PS^ncC6Gvxe% E3M\_2Ů==Xdz/Sb 󸢪Ä3$$L3툫oo_$K K]-XupBח@eI6ҸSꪧ}1Afh{^` +.9c%ec/2T]P\0w qEc?3fpdc=ȩ~@[7S.@Da/A:A4dYb}Km>D2v`׋"Bnrx}f3R(]8p +hл }·!E߻:2 U0 Q_;[ědmŪ/q?P<Q*o8n{9zNAΞ70r<yZDijx=Qg!7M%p}C3R[ֲ~oP"kZgm KBQPUm X"0C阁 B_E-ntq*0씺˻Z-7Hb]I6/O爖~PwPsWʰvGlkhBqEzph=N= å?LTyw){nȓ+@SWMqOHx@gq27QO1YʌŦ~++2]z~~Sd4e!lvly-jaM/y:_1.?ac(uID TT G⣰q+oAM}VR_M1.%/눅7EpLyD 蠟*%_rɛ7č_? 8GyL;7_gKvehf9SwVe^Ѥ+#c<,:QK9!Eu{J>JV0>(@q'k3ʼnp}* 4:1FOݑ!eЗ6`C+P|B(}O9ҏcZ^ފӕj́@$cŒ"qAc\v]%*00.}Qbw^B[e@,nٳ>{w9NGD(`0 0:Ԉj"?'MD%a3>vJ"\q +l* 6~2i<5yM uS@# PTGwE|kk^[}IcKLvB&c|_Qs/^U0Sᓨ Uԟ,+VRE+F=ETݧס0v@; oFlz/,zCB.#*{_0Jm GZRgyPvxjBVDO>@ v=ڍUi"'ƌa-vy FՀ`!Y UR]\н $Yb[ /6[Lzlݑ=w)\cЭ3|ӨW@ڮҷz|<>N]A_gI1*TJfÈ7voyWHd62ʞn[dY,I:v{^ޑDd4bf_ۖ<Y7+- ^ZϜA婦p1r$~w欚eOJǤt;w&~P7]Q 0o}|{qnMxpV$kA;CX +AB_kn2*PsIM⇪Ez2?&i5HV-=(atl&$3v}Wlqm8^)tO$䗼v |choOFt"l!(A m{])Y743tۘN5^Lj݆Xmۏ}!AWQ C'm^:r6&D vm⪅de'鴢NMභqBF.|wJq!@]bHl +VCmwipz2q+![qzDYQ%uS=q\^] x5(>:Gm޼J7:@ -[!-W[)a׸~ઽh.L6oh a`pB#ujQt5@{$BOy|Y h8BQ + m}^]-#\B̍Nߎ&b@ ZMO@zӁa~/Mu% FjС&^8%?ɕlu?AAajdzTa_BEvpeKEv|Op}X7xH^jd[(ꤕ#q޲)χQ+-"h'ڂ)ul;*gd>Ma_{ka/s̔o_WDV)u|(Vgt~O00`dn$OWrdR>bk;5'34ut#>WVkG =T+/WĄrEן Bg aꧼOwlmAX##2 Z5\j畳^+WwKս1# Da%JI WFubLByS +{/x(<Џ#*\Ud{__O+06+$ZHy-!A۴nZ +:=s+ _묧9xZ(qAܠS l(=Ӻ^ `Ԗ x kg͖}•}R֋tӕ38%zݙzC'XکPa1@TB;+4'gs@%4|FrxP*M φzyl?"/i)#8Csl5i-BԺ塾x:Uo0+_-T-Q8Btf1UM + )/n@|E3N :~F~WCъrϏ:-խcy9}=|F n YÿY4(^ZҘV4g{&P-x uwfL{~˝ `ݦEH{4~c&HstGR߶Όc*TzUDvЬ|?`B +mUDJcGz ^HGD΅ #*Bo 2"x@Βd C^ȄˣP1GDݤ']R]e9tGJl^W@ĈNW'ynky.|"ybup=xDuY/K[)L2F,5fhEsƿnK{bFm_;&q[ EIGovOQ\r!)x*S/t+Xne刊Red_ws "#47ʙGo8eIH$z@fKeQUIMIXL1]JcӂJQeRH&ZhO:G5:2AYplΘieIAA /++G -Q8gQjqA D`-w?H.8S>=qA|+2|83 Cs?օ +hvPFE[7$5[|ȑ.,w?82lir='4xF%?}Ѷ3E3ek1Tݕ{J{xbv_ސL~l?#nM>>$%KgrQV +frH[!p+ZI qM%G;ېSoC<牒P"TQ? +@MsE' gΣ();@/5&t,er3P.x&]bOI g2yN&tN_#,7b`fQ}aBn\&z(Q[/Xx;>W8[C9ufL@|8oq{nrgT߅s Bnil&!?RE;鴿6mWf*2U7ݜP oRm4ӽ_pW|\j.uɯeHtaz]iyGQ˽ŽaCr/9r RaùEppmskrX1m_BR6恚u#" vQ*R&%QHDn2-{[D{JWz)̨˥$F/\9*dICvoT-iE?, Y Yaӌd._VZYk?菬*Ö}7Ɩ(ʰ v$U:…eOǒfݽ%6O'AEKPRN:)C֧+maTuiYZ|L_Imx@e7NDmr%KƄ-D|W}>stream +v#;v;JDw=; Xeoٓ)KXR<=z7v=W2=@姻s=Iy%5j|xT5Zdž+<=vC*3ZZEr%[}mBd "80 [R삘>eߐ=+Jg}}oo %IF ]dIaH2YץgTEЭrh~P h[*1&غs"y^.24f~?]XŐ-ٛFxoESc@,/#V'ǥhЄ^uԘ5|\ Kٶ:PWY I/%9ӕ=%H}L<]GQ^,IW})^~ Y9n|n\/shnTvEtGۺ_ߠ@2ba\u$j><8( )ݪ`Ξ4(([-e)nD(~FN ++L7M:>x 2(vqnQI؝Ð5$MޕQi{G!OK\^qT/"ŐfbTH]r<^'⵹8D?[;411Ǡm56O-4][u =ZRB&p]y>`[G3rkucXjڹ7t&I}èsywL# K)OdxNgv;9[t):~`8ssW2w6ׁ_*נI*-lQ`a4z;<=[ Ϧc*(`]8xlnt_3.j]s PFEצ7R~KeT!8 f'G/vOVu[Q|FAcg-z;h^6r훈(hV_^ X^(#~#gZN(st!M-Z +lz~a7`;(zRk۵Fcz}cdԷ5y?yyF+DۮnFϯGQ6ъpH\.Αԥ"7FUJ@`o -']Qk|SjYit/g=:__xƱexT՝UGX(kqyN9&uبGd>K9iŎ&E7d e ){#=Fu V6@_Aq[Zz- 軈ÃEr޾fh;8K$8%-ö!`A_YJ\ni,$$z?FGLY[,I/[ݳ>h'=tu%ɰ5P8be Ub*γvI:&M5 ?*'}-Řj2.[9 hHMOc^1AKYu!zZE@ ~`򔰺8;p$ZJ4PK[_^:IS%zA_[ޮ.}ߞ1Ps#OzfXzYO(~VqJ}8(hJo{ @FDnR_I*QO~؞f/!ks=WAM&l6x1_AQjYyOFr@-ZR3.طh6G+|Z ѫJ-:kߢuȎp|krt{S +8۱ 4lVg5v'U׸o nGzZPcJVcum3*gv=j@n{CǾA$'+# w\c57ŵ^PI/i e +P^>uÚqh7jY%ޣ?17 :myM׍'xtT-!֦##v*R:]ZzIWwC=TESQű 9L,wn0iwުՠu`fw4I[SY:>ۿ!h?o>޶SV{bv+N:J+Hv;%HKI>>ζ ~sb$Cw_Ԝc}p'M=ρ3(4f 7mp&|JR"#X9ar B[0Lf]j J/~K0`ξ,]nUc90I܃y~չ)oS~{ۄ@ߎըXo3 Ώ*b;P*O;7(۔ $)s(Țt<:l"zDk}ύ(Rqrz8}1Uz06=8W[ Xߪ~D|H37 I¿E%g6RM@8\Ǜ'm=k5m%~].Co q|?+5wwq?Y|ZK@~x!꬯ze3t2rn|zyJwZ5?_QwKڰQ&{u)_%XGJwx 8$_!#s!maB`C;T@'i=x7 bkdXgwon>%tLW:}/e#W[/2Zp-NB.0K( V9,W5 rubr!QV+ +K3VP 0 XжOpXF&58z$+ezl'=lS{|^N74{ўE$SԮ7u&ŅkHO|rE͖GvÚAjoq}?΃+Vqz֮@mrq;V5ߎ,` EqJ+9^o +~}' [B" VGG-b  +tt\Uvia"j>!8Yz:_򈅿.3-| ~D֐sqŸ L2r +%(œx(0ӅWYϓ0[?6 U'44ψ܏zMH[Iq[z]iV mZ!S{]uguu\ +oҁTgDkClLHcg\Pͪma^Ks(]]}!/^g(Y q2"F u".@^ (T< (CI-0B3 ig)Tփq/0IIϛJ<4Q)^w!)9$e9L{ζA$FsYIlt#.@ߑ>Ql>oaOr\c\Gu?_56|dm4 t PG0ڏ'#v2*kuY.i>O{'򟠬 XF?Ո QQOMPDF@p=g܍62cDZe`EAJ҉l"dꕨz3kD! IG%\a99ywzGf.!@seU)j@Ϲ3ﲂ3jT|nemd|~*ZIK+bbbMU{ 8dzџ!;:_WJ̺|#<񍘐Ō # "֧Xw] 1,JRhsKbbKceǜצ!P)f/A<<` ˎji-[/Bgi2)*3Nmނ/P!UUB|(z)ɜn^Z&#)Q[/5#ҴV_s7yڝ)Jd,!1@󱔝B:^ +CrVgfO@py:4ܨ0rz)+:6*~F6<ܬ=m +pֆp/)U Zh4MĈ,ΪG{ݏ(RPZ>P3G랭Q ?"0c=YWߜK&{W?ǐ@#D\@|ռMF7&.AGS=#6wOp. +}dQD)yށb`Ы3@@k{}3ÃϮhU UPSfx:rf3lMA]sz8S6䶮Q/H^5)+wDԒ D7'(:6?OfwE_ ~O<+}D!C5V}Q(_ꫡbN8Ŗ`n +s-r|]%Oz|hՅ:"x=& +6) {tXBrzv4Taw;HVܬg?%`"usRiDo`z׆!pJͪ2.,=P-\yD5#iQ[;15҉) 7G|$CFdҴc~ʍtt-zvx~%^0 +JQ.+[YW _koMqLԤ0ٔY:gL׉`!5_Neg1åҍF޾%"G=%`HZ$99@ߴm~&=4-]!J(9VgD6Uߣ *ʨ+Ph~o]T-c<&>43 BqZ +7s#^~;",0 xX\IC \4 +!\q]ŮR/>aac{8ӽ Y[u-2PP|{suZl]qd=MD)q!XIuٺ)lռ y(YrxCmQaZv`Mpy "uP(t1Yؖ1(osk{rZ-0&ʭ<6Se7@@aӈ׮ys%G-Hy+PJ;ՠ-vɕLVѝi;%bw5|Xg~ |sN*πqF.fT4ra 7Lx$tAߣ/9tLȎ@I2qTB'E>F޿9O҈ջtMȸON&Ati/ԡN-ſ$4l#/i9c+AM`kwAm<:gͧYn+GTj8rӮJ֯Hv~4 U/)N+۳QEw&blY +ޯ*) +Ş]'=} QB}eoQmC/k#ɝPpM v7q+(Ϩ[PV򤞇4v WZhE.$)͸2H+r|RZHNϏ(8h jN}R;K~;t́U;zp^S v':cWү`g&Į˹UIρ^]`dT[! ;1= +to|;3p$Pb w\,z"^rEy_~r>B ɏ??6|F~Uȳ`8CjE|}Ӄ@Ӏ?pJKc&)w}* L/ mȁ4)8svtdpuEjKo0,to% Vĸp7HGp:W:4׃Z`4[a4eGwV8`~8A<ZY"hA>K'%bݫ,u!0 PBolwNH +@`zzFG7UZBh87|3QNk)wdLHR/|2AA y>K)ړCsN0iȯz~"psc)o8,,ZaܴY9MY,{] w:p``cθ` 繐-8smYc-UƅIqp?!`ogNʞ@&&Yga)xtm |rD~|gF65J|JܿρҊo#[J"?DULiNF̨:.xMQҧꫢ% ϵ2JzIkJ|\Zku?`9 - =s6:Vk 'C6ٗZ6-z'D9O\Q-(+Z8cQa^>v㵸U#֏J[{WΡ+uǴ|<Nղn0l-c< .0*")5Q/DzSnbPn=dtsʽ5Dmaw@6QŨ>!7A#te)A r}oṂtyBBv;->Us~)'0OwBiͫ6 g9fxQ#Lޟ3?F%uQ8Ú" k/bhcy /t-:R047׶X'{i*L 9uUFi&$ t"$B +tJ +'ZCpWJ@J6TxBE[6О\hqlqp{=="{qRZ?܋+ gOA|*ޣ7Ѵ) nc`r{ۭ Shbu~Fy ? BI+539=ўʄ|glX.MCb+tDM߅U&!c][ƤR"Q!-@qƥ8\uȅDdE<,D\@䊝_`^ς3*|y8JDץ$`=j'[5!o"ʌ9P|(`j$M^~yZ7U (Sw]=ig2$+e}o($ (0 \cy &rHn/hl0e8 y|4N!mb)Xzg r<  E5&C*B"ޭk  |ʹ4+WBÍ)ϕ^T'_7 + W$'C⥈ 03(֟hsS5C-Cpfegjޖ+\=ay-+­[M9yzpN;#"O&qe<s>K[7F3(kl4vʹ@__(۱ ̮rӋ8l W\!Œ!:2³9x^ºsX\t,cYvƗdJj8z3JL +@r$RϨ m"PB y/MGKB5L`"eތD'ad_Q5AJ{8c_uS^?֔Zt+G!E@&668Pbu|k+b>WtxJrEKZ2}\_ Mġ΃a3U4?BF;lwҒ WҮ7}#:%)/nByȆ}[cW{1 r( ' U*sT2a”̯]D D GQh<~LbHH#E%t;ah7ܧ3Q1"ɚ?-lMBh|P K$Y!I[r"5 ^N:Dra+"jl< .ڕ4ہ4'ѭܔq\$ Iz0%#;Ql(<@!yEsU}3tfHjr-W*JRVTJTxɳGDIR>KZ_>=VIZ#+gZC3ƍ` +6hS7fuhmp̉-'mtB=b^Ke׆֭Ap)Nm[,HV1tg>>7h%8(.!^+9>@PVcl~!*Da ~~8>cH;!d3Ӕg ٢V4iz̕tJ.I m[Ҹ's!~5!D +q*0uֻ5:Dي6yAv !Kw)QevO+kN О`k Ψ%f\k GrZ$Pr˨4]ey2fބP&)(\[o/I2[=JJJˢg!)oXD 9~_p۾[1OF܋EBNv>LQLRúz{?4G8#b 5g"N:i/ qZw TsOGv*XVYPm%)ke3fWXsk 1v]'1жz-Vk4 V\L%?yg)DO={[Ȣ'y+7mh*uz;_Qq "*By&|#n!`G> G;o{FE;c"P+at^[Āu}JУ=_)[٠:i6{*i\H$z 4bt:AfL{H 㴁 Ʀ!GToሉNoWwn;DmT;L>93p"Tt\2DHQN#8Pi_t+ ˆmc9pQê'ynE6̱]y>^ږ/\2P&Ghk{oiAxOq1Vjn\:MAc&20ʛȍ,)[˒y59OP_X5~Ф>c?X. Β'(szڟ;usxx4_[KkU[S`Ps[<,v1&CmX (^ +pdTze/GfcPR`V6%Z (d#籼<]6([rNqR>W BBw2,d rD\$8'w[Ln/ݦ}1܎gbֿΰPT5<8sTByvWeR>tDh?FFy:%=Ŋr֣DƒOѧ#B[(mJRKjCK/?2TQ@݅uB.Hܑ. + +ugNC#CR'^DuF$gMq/T@_uZ+%%`?RfSP7^I/*}s:GR vB%ƪl#o*m!+_ųK +/vv*g(HRTߠl?{)]̸04MA\FaV [ϛ,+Q?Z3beH8G 3:Xd52xW +cEOTjvk z;P?ED}M<$1(@G'e+1!"zg(f[q&eN4DQIYnG2Z|)0(ks֤ edOFE1] 2+` EQ\&}zOy@hIT1*QbFY '2e +wyP) d޴>+,C~G< ^ أl}~f۬@ؑ}vIw>7Q;paEUUqw[ggy0"~?(^GǕBm\H/Qه@v(loiyS@@uHwgjmLz6dn z׋eY08[nWYSZ)7Uvtʇ>?v%۽*f6T as1ڷ)ipD#gmbC1nhBfU3MyM[ufXe1^%kDlMuRyNXQea^ѝ ~q@ b״a(*uua(vC+-5%9ZXrD D/˫~ZU;ϵW~ž&hX@"SD"⸿.֝ Fd܉- 6%y_.ׅ@9~0 SD+G}5EGLY9 ۺԩdtndg앨J +^A oEQ~GXMXsN0kyƨ4@A o +L PXb.I~[ |^'F +kF&v'Ib|;Asxf3W87, &<@#p +iɮf`<1N|,7i$QlhT> +a6`sk?GA~~̎ȟsxY7?7qUEH}Oy|3ϕBG bBAv1Q̬Wx>,]m˜w=s|a1SP{p-RV>tvU_UxP֙)/(i7#GILnv+LW‘/z+д|6k UϼEk jASW+, wk:E LszAFɣ)$sd7^))|d:aUµ#ke "$L cxKJR0W2i8Do' jSnPFԍoRT=dPV@jG(ۨ8(Qկ:#Kkֿ`E0l@kbX"(|О zF~jFq4+6ķҍ'@};޶ӃX&ْ@IU%KRk!®li#0e0ǃNx ) {VFFg23|G8Jo.~zwZWFJVd Z0AbG'~FA)_GiHNNHqPtC } 8tPG8rwہ#Z,\D@n(#S6 +q㺡Kb"Z;؊lc묏zj_}oA];ڷ>ߛA SJu',i.p=Jx|KIx/k̊bľe" q<tҟ@DEit;nhK"(/Z,p(&6MG`eٺ @ġ&e[0T5kD1dAe@Zn0fR6~t +#ڶJB(';xfVͲ-=1`NHÈJFѯ{[}"6Cwaé~ ҁb] #/{n3aSF\0bhg^%co<XBd=r8c&:^sW6MM LF貓iepu2[C$2uϾߘn*3;zHjR17-hEJܠ¯Mjc#w L9R +Ɛ7&&es(Z'Bl69ӶI#u %;Ty;d +ZQbk]N *o_ 'n= Sɟw` 9W}Ib&csL湵 z9|ț#6ȟh9q% mO񒢐tpt؍1$[CHuሹ!J'.  F-Q`0[=(,=پ뻔tb8*)}^b:қS" @Qh[Ѱž3ĬʡiS'SƝNDֻȫ@/f.03Y/NcV[V!T I,#Q{ۦ"f-&b2ȻkoƯ(;ExqYhIӧµ*"@"R^Դ+7U#:bmUZA41Bl_i"{Z, ÁTV1 R"Nəq Q̟2 Y1E/#4]ZWGD*;RH8}DQ4@h)P(qqoXH01 ⾌ { 13h. ִ'M]\)?h hr>zGf +GѱGۖjRe=4%{~ڊ/epʃnݳպ m䝫 N?EMt;[ΥJے9Vz9>,v7G M+bՅGdڊy`%gҲEy@j~˒d0QVZ+?z „o"H} b1TlZtF *-.) (2YDږ@Ң lf~JʽkR#L궯(F -' +ܯ"S||ـ{3a` p?OyKL֞ 6rUHQ⏈MI]->x;'·Ѿ66u#@Ó; +8Pt01F\oӘ}XlA\‹ Sel =nJIyjz#QִΙ[+ 4=/P<$mwHI7˓Qvy4eNW\[ KvkƏP +Rβ~yJD&&UWH)8m &U!D/V`YzhD1afω hrzXx6e)=3ۀ9]o/'C1PRNG9I=9k1dz$BNq,(H<,@(ڿQAyB-'kì f{YAV5*T ;/Տt*0:u']:TE;nJGtgSfWQny4u:Rʦw.=8uL!Wmu!4K@&tݚ׵u\!Z|ʘ}1wJܨD\W(oJPbbi R \ W)iGUVt_$lw9;ɠ 'ϯΉi&Mck=Bя=X8!n$yC< c26>}Ԉ K-wsU9[ c>#Fn`|p|q~d2ԸdNmC szf&JS "5^aM|Y] 7 VW_S[DT Ro~OB|(`;Aa~8Ej%**%DtM+1B.^k ڿ[m4࿭Q-&$<<^B*o_ ߹Aqc&"ԙj,PTSNH̋ qomc,ʈ#J?5}dD ce.{+dd%Sh=9| uDl%$R+ YlkM F>#nX+ᐭ4_ڤ]-Lgya'7T%"_(7zuWsM)<Bkq]mV>#8^h1O[}*+Ü-JQF!/KzCڹFL~^0 ++];J>S7u={=qϙ#)yhPք9"!Bԣ'I\OoX}uzӷdt$_>}I2XĐ#>Nߣ.m|r5[SYYYQ96U^?ϻ{j+j*.ҽ!O:~8Rq;DB($Xfm]aJI uߢΕU!2n0+֩63ۥ +Uxg/LE*FQi2~ Tm>P(8٬5[H<fD+]^8L_4NjR ,ij\߆[5(H,>=%bv:w` :7ftWZ|w(T"D|%DX%QΑ_qhHC4-6Tɴ $I[sRa+Fr;l )⇴.Eͻ FcwD;܊`Km;v0RVeEv5.+(M93"fFdE(=B +}i٪7֞$ϕZX=aj'9ĬTx¬[C㤠Apl7,w "\J0淒[s4hղ Iv~OfԞ`?jڢ jDccSA0[^0O>nߣP̤47-_ɒFHuZ3/y(ߔnxdy|9R +|Rjx +x~Zib-"Mb'+h#nB=*$ 6c>W" B,/^ʀ(v_IDߢTi[Ɨ:Ρ/{kP E(.oYӡeQ h 3ܢ2k1?&u)Q&oG6(HA>)>D) R^QIfX++ pۍg 6+p͉1,?c>Lrkn[ 6cED ^:J'Se4JcL\ouE;=;V@a!h/^{D[2oQ قR@K^stbnn(?4iN +a,-sZ6gYEYP7;EW빏Q2BSTBG`k19U1ҲBb!vn϶ge՗(ӷFؠ`K!˷#X{s W2LfC\qtܫ3Lk:2 +sET4-=]J(1TjbC~]ن"?JF˂3X+Ik(?$ز +p#1Aڥł;L]Onz[> =pJ2s9UAu39RM8~-TQ vٿXG)J:dY (Aw\h_xWۺ,U_36m, )]9_ԋxa~+rCy\n,V'b8b)1"Z*[JDkr*XwĽ#HS2UJ|:B^㻴"ma t5>)FV5ݗET}G ] / c}gq_逜K&tZ]4D3U"_eLuX4>'10#Xܦ6d{gc2VzpJ[9-6UJuF V~ŀXQ+(CsݥFC*9ˆc6P ; -k*1\nSoDBF4 +y%2ӡgxv+*s"䝛yQ5Tv7YֺHfʩ#D!#^ŵaθ ajS'ڀ 9]ye{#%*uՓ?j]`Mi}jRv~ psXB \D~ ܃v\}6%D[Ty*S5C1$b>8~$;eXQ -AtK% }nx8HVߚ!PUǠuZ) gminO+Պ &Du8Dì-K" &Fln4b: `Br@! +v54T&vRtZW>]`>]lnQElwusepxԕ +rZ}5Xmَl6 />'gơre 0A|RF}C>Z +LkGzȡnѴ.4@M[/JeQ[nr;Ծj05tybsS#8+jL]AU-39M;g"*1mrUekmGq;^v(̬\okh\s*þ+|fJ]oW51[aaiQvL <ܵ5>Vv`$lCa:~-+?dtr^Q@{HS 0gd/)( v|3Ƕ1w9!d:%ލsA̝ 6##l%$ ^j7s.1cȆ 8-S P~:IVv}c>-y;/& m>G:7!]4A^ꏪ{`J5HS޾y"Mȱxw\RhڜFgdk\" ];\7J e.Cz%|dhSG>0 pJt5Y!]Az#rOx%Y3. t@`Fd`v\_oquvj_ ַ-e@nZoDWh(2`N-2neJ}db44֬V wmj="XZ53m{ϭ#Y :^_ˤ_,wu|,gRoYۯ[y Nu(4 + ){-c}aM=oQQpsc^蹝#dZx\k=yN.VgڇW@;˞_mt䁱MVO5H]='6ۊmwCдa[^YHs$ۣ $JrICzY#"ƸR#R)$Q-RP9PڱIɅg2]/y~⮑:o^|v}8t𶗮QҺ&9qđ hsq^eGqxs83N,YqrpSfcL`|=QH +\o}lom)&̪ROS*R褏-ǧb-TgXU =FKH|mu#?( ^ =oS2 G/.iɸT'G_._K³.ε`l{Dn= 0TjJjМ=sz[ȝ;-OhPX.f݈ Qm_voW;@ vEMY leF|͹\d֙@WS`1YHa^"62mQC7z-! V&fGDRJW`I!wVnY㓤ٷfg~' +6G rT̑*m^񜹫 K3CwDY|Cnӏk XoRyG`.h{xg\TC+dQi)b՟J{Ըcp ,yؐu `{F˦Υ|H>ҘXjWߣw?4s3o>(|+ ^EL!-A›!R06Kp ǩZ6/zV !x|8 + дb'*_DZGvjŭ;@}Q|qEa7={_WFQ8K5`'|FG ۍ#\gס{7> W j9\) m;'%qODS)R^xZpv6IQmjYEkݢ=\ȇ>H,.C9az p(5^qM$bW"b-#{6vݺHμt jMMBZoLGV +pXމ;{qmqU8(:2: X)1߃]iW+u_{Ί#]-^ul }hT41^C"* M z%/#Xي,Q]$?,Lޠ^VӳƗ +_C^Ćzш_Ԛe~e<3D Z uJ˥ЉuS2g4aҢ1P@ƦUUv57ךݰdqݟ GAϻd[ɡ\Pj0[)C|w`"\ "5&)عdK6ROlBD +Y:D"Q4Pl<\`v6DĿ$yy` klZN'%`KϢ_1zu;k5B"xyfPKa]J;l&lO?^Jϋ]|i%'zHA`W,0CRw.4.|ֈd JU#3O-VdUU8X$ʫ ]0 q> 6d.[6ǴRm6̸\|jSʤ4L|us:DugG1Gi24#ٵا^5?؇3F C?1ԏjLQUㆯ8JQWJE|swY ,uf>Sxo8_6gMzslw WwY02(W#F')ggiIkVpFL%om=k}9ғE%BoM. _2umukiU\{|{L|Q*S`n[zCBzOͿU{:hprsO\H(ы&=]qA9":(6XC,Mn9raOR%#Xq;RC0XT\dHUu嶍|֧QT TAHDq 6z +A mnUxDc(,QMqX ܏sp a5;-{(=OM*NM`P;0&Ax!v?-hȱh_98cX55yiwE6#׺>(5߸@EЉ.׈uo^"_vШ@\>s/w//Gep[_鍎uȹ#nwUTE>jM>r̤81ݘTh6DOu=N&F#Vƿ=G':DWiSEBzsg˸v7n>pggDx4P̀GzS6bƹ_xc}#:i)Qפ|OjDWqN=hrͲ8Tu$u(vt>@D:h]h+~bz"@'҈&3p4If\?uC PO6Q߻F4܄Typ[8@{ETJ#K.[Y[y*;*iܺ5)i )aw5q"÷n3 Ia4ShT'|K"Ek-V~r*T+RfډYG dH:ʨфj&-TiݣN~3~Bn6)ZFqnUl zbDu:"07O`Mj`#8=./,Qu$@hE9CF4h֚YUxS6o-^b + +F +:2R_ysc9py)TԂ-a0=!js{Hny$L.Ub;EOj:Cf=)ld?*(, i ~-w:@+1'TV_+<Zr)@C V9JE3 +l삙%~j*j ~:9kC +k?nɠ;m0rIkk#op+|26ٵ>le6(8BbNX+WC'ޭWDhT<I %m\@tlL(yg[13n +a kP 9-[T@hz4@Yl<ؘ6 Tq~Rh;6j3bAo! p'c #kycUd+.DQ;5F3nXt`0G@^\??ۏ;RF ;0n?:S|JAR^ +r0X4Cm;2h ;uy}HSy&Zbcp-Q{IWn.eߨK\thQ5W:Cx*TAeo`X-8 03ZNk0_ڛMuN^k dG)׵y޻$CzX4:HG"^ݪHaHanxs6@,Щ,/ƙ$XpW +xE)6RZ֡hGFNΟsكY0. E;C&WH4 J9z]9.DɥP[kxTVdPnQP`PDqwWײH6QRxuHՔH}*Y}DD=TT,QdiRsQIʩL o' {0)MZ$Oq?>$ Cs g5}v&o.D7`;gW9૝ PEVjp3BИiv2P#Eh:,鰖2B?sJ` H+XGpG kf%'ұ#gXɤ9Bׁ`vF}ԥRS\@ќ舂Nh>qEA#I8X g\G5 >kȴ'|:!B!=EV@ꁎ^k?. +g{ovi=ԵPePv}O`~)wҥ*ޥ|<Ԏ` /^Pϳ:^N;/)JS!onZ5یםu;Fb2seDiED\VmV#2-< +.b27_pmLh"y&Ȍ S:Ucګ_V"ɹfJ]S#~xo]8BW-LVBhzFI@TfucL_}l;ӭ>ljϸQeqr:֒T7)A{F)F'!\ig4ֻXeUb?G:.?,WH tTN`Dl#sWT۞{˝B%$" zTwwcYTg3%%"iWձ8H4l­? /<ܲ`<: + 'Z@H)ag|yYwDY5;sX彲]ɜzo(\A!c!P=7e3fַn΀rNg]- ]~kRac\:{,11_AW'+Lf*h\A#k+[Jn^4ib[ݲWC8#"QꝊUkA1z,=iH!]cg$ mcb-eD܈4f}3Nm:9)O9Ѱ;3בKJq3(6( fDSIEjU6LNZBhJNk҄LQaN86tnNh;ЦFLEGE|{jCӈ]\=ɬ׭YLWHX!=#_zӪa CLR(^3-( %ps[3+$&+lϛLjH`=+tzw U5ʝ8!幫`hS@YBra1xe +PRԋJzd)-Uݪ,|Aד>۶8S?8cԢTgpdɵf=Wh|}.boNΎ d: +#to Y`rϧ *0%\p* \1p VkB']$ oK52N԰JwJߞnzR~mg'zM+@ v>TQspfP7"#^a(GS/[ʺF 8Exj ,K&ZcN"JC4!-xgHwdr ߹y}AA; ,{ OI",-r#!JI>RtO0 Jx%sKgQd I~9T(qPD|PaIjgúy[:CUKV,gɍSޱTz8`pP +1 +8,=a!P,H3sb ñ̄Jaf'J1s LCe +Ôι'64JDI4T&TAc3Dhe+l%k7]Bc uy5*aUu)冲>Wo!Z5Xd}0 0/nQ:ZԜ3^ ;RT9P\| F P>_YL%'k[C6dAYGk xϑwo^2u'\HN|G]-n/Ԉ׋g3X* sͱXܗ%huÂ9SSdh`.G[8Oz.6 ͗(5(gU6ӢueQIlZ$4v@CuB}V*k=!݉bG0BWGA!V1$hCTTkT #Up$ti'6b5r"WU8R[Db_ TT:s @-Py_-r%!km\0Yxx@"H69\XF0lf 0G;3Fp(Uz"ea`vۀGv-ӬAIA,ֵ5i4ՔK僼j*oʰ~z|I0).%2QRHƒ%H?qEV)ȷ JDr]+`O8\v Cpg,f? >4=On`]%Y6l2KkTD}<%Ik n8\ع*Ȅ~`unrHL C бu+:yowy 5#28<0%f@`Jg>E H]Jc"],5|8rKOvM~ٻ[rz|a@j쌠ICDqcqA_½#vyid5=jkf  bMF5;1CUs6oVcX`LlWk-yNͤe]CbP4hӪ4%@׈;3{ZcG"ԭb =-CeJ7߄ ;˦k>0An Ejd/tb*˷% /6RKcc˒ڠ, 1qU( TG?w +(˫OV*a OuS5N~O*|39 ,A6f%^ժ\#} 6KdR-hfc$Y5O9$%$ >cvkVb;m28MJŝ3>Sf^a-qpz'U=;8ݒ ^J_\O +͋3K940HgAmKAm\όJ1PU11ףb~QdgJFG1d$k=s+)*#Pu\ )vBS+Ai5 +ӾJ% 2؇s7]eߟQ(ϱd1/aDS`3֜`OwPFRJCl@GjGP#0?,>k>}d< tr`PPgNq'RB RCM"ۯ, \ vKԭ[4O&vxCsEIsFwxtymG ,j,>Cc0\[X iEwBFqk"l{,=hSi[H2G1d,.' 4sz,-E/@+M+"|0BjB=kMNab|P@"nBU:=/(OT|3 gw8RV9~n'u\{ +eϤHLK:Gb;,[ef` &0 ATq}%u7$w+-^ =3FKumg"៟ do q0anrܓV<5lִ؅q +_{)EII xHTMN(ޢ( 2w ++#>>W6kjahbڐ+(zƮ\ikL%9CP$#g<:ҫa㰒N 烶)WDM#XΙ(UF-د#Q S?> (Dp_rg'W=UbĵOAx}w87[9e#~m QqF8}[u%]@zSVyN =%'M5>G{мPX0q-8d1znEU^)bn{\~@Xdr(ȔzUYY@ +G-뎛k<7( (gY32(;ytr4G^`LOVμe *edXS6*`mGH%-o>j'o !bWuEQTyI#{{D*V]~\ ZR1cq)r\h]6GÅ(k$?H!(j'GXh}g_-SZnX"Ӌ17*k  x(*X\[/ĠA(I%1xVګm.UGd֕'UbœґT(`ek*cؾ՜IA ^=HvQG~zbm Jnмr]o+ ek/8;B +GA*gPq:\5`(#^DAc"9bllw)nlx\?Rp1ޭ^ҼPۙU֋᚛,uivrRͽD"ውDD8<Jl.bhfL='"pt*Z*}7%s}/͍oQij1k|oY:]~@ucuk-oCI-$u*DW -v[@tr](zzA5LP-IE׺"D/;`,bH=i9Nz 9h5˘X1*j1mɮgz[DgDmqآD8X)2@|#BFJQ^ߺkfZ{MSSe+ t`Dj_z 8[]/QO}C]6D譕 Ej:JB(47Aې*"{PnkPE6뻂b}kugV{? g&QHJ +61fR tOQ1q.<QPR>D"濻;k/%F6`a}SVf'Os@mR\=Q?\Q^4`QBDsVujShx%ƅXXTO*di"ou#YvF(!p%eK/G@-4g ޻9j.u}rѯ'?Eljefyޢ` >V5~㎞ҋ +뺽 7Jsai+eОA$ߣ\CL6S5dl;E儢s TG +҂6Om0P@%ůWzOYwDoOH,rQYr~j+d&U^/V+Ex5%#9#: vᨮ fyn~0t3@W"%rewz^,V{3KZzaDݿ +5F½PXwBOC}3NMv;aQeE\NA$@ DnaQ=< ?粈BB}5nG{:Yq݊0L{\R6bgL AIXgK ئuv.M#08mrn)I8.F"zu1sG +?VmuP-fsx,ZX0b b!ͰH'Mm*/.IRjhj#[Jples'Ac|Ơ ؤ n8V"?(9"'GQ?'l17T){ւWt}'\Z4]Jj^t(kHPJ#;I8޽‘K3j*'q)O]x_k+=O9rZ#j -Ad3~QsGBXLlP-њy2&!M{Z}K ?Iq"Eis>B+?vީy_(\ޟ:ۘ2Bg>bn,0OKw7=ҷOm?hYYcȩm&F]\9eG^~n{;]s_e+kZ~E|9G1i~oGL 1F(KbJ&ME]Hu)Qa !Y.*{o@;,.Gb(5EXyZF2BlrѺaa m]pJ?%ZBR;AUءu!g&+ٹɨރl8m~BADI"b}$&NWEDP`DG9VKݘI7 dj?b[3jzvCmeӁZF~.wWp^<9)"+m.{j\ନ(U VzvCvs 6z{g,Zd)B3҈FQ.<_9LiO5mLdU +¢KN;qQ5I -H5! ˋ]) x|*dU$shb fa3G[w0\># u$)2Yo׺9@fdا`kEs@A*\U^G̺j sk|ըͲmyӤ_8x.n< 9[k +! )ya۬ꏞ3J%$Ċ4$ҿu]~;߶Bfd>_nj3%'OM GӸ*EʤIUDxCA?범}?!#n#u!%A~RZC_EZ׭Y hŧ)˨ޣ_Kf/*.S<~8XFMA]k-gFhޒU׹)=]}'2+,JAkwsqR\m%3@' ӊLxsxs [z幙J\wY)!r?pgi6ܛM9^8UuW-~O9}#|#vGh{{ƑOD,޻BG: n PCT@U0JTبܩ0o%kdaccE޵#R`Y o㊱U6=]AXx>fe- d=0qƖa[JL4QrWI&5(AMD~*D"8^)XXLoR:?G%!ejEcW>#˕SP/]q)!jKKƨ3ASk ·h72t|=/5go",;{D&3YEwAG>%J?lpI^b1ɄoQo"`dHw61ea(0`m?\iB .DNrW +u/e}=}$dNxEX +drt*6ѴMq7k؏)GF枖G#B($FHS@!K: cMv#<|˩K۰ +76)WT=AgvI584|XRyGFRnwAQԾZ!* Q|p? +àw~n9n5IHf'(p`ADV4`(%PėQQoHl¢IQS}^5':[=ɏ׍D"gEy&QG1'K38kUm 45 +bv1Ζ"Bc9dG~~^zr103W *аrqGgOGE] ֶ$*L?O̿5Cc΍& sK)bep)Y_SS!\[.cny~В`Um7}#2{@HpciuHS 7tT-}0z+BM(g>;ᴽ3cv=`|!kq)):b/-4kVȳʀP,, Z$SڤS)=N9hfuG܉PB4{4qO(YbW((F#Y&o +&l{{ȈnAuEZ#jH5Dܛ@u#`@UJI~EqR &uD -lGVu8tHJ>Y֣}1bUdq(FjЊvP8\Ik1y hiY#@H\ ]ǠƜTJ83k*iU= zh"M.݉+S&9R%Qc߶FQv(9I^Oi˷ +ӆeZ𥢈Qdc gyFk[azcɠGQ }t8**L^A+Yi:oiT#nLN>G?O-\Quv&t xhJ;BAE>"Ѐ5}MZH +R1;wYG0;叁ȱ\ov+i82`^b YL1QXadssק>L],bv'F"ڞ+ij0muBp9ԍ"u^82m!LrhK]";șl`q|k3HRD۽zg؉W<֓oj[z|-uw y 0 +ŔPzl +x0B^fm~rO=-Qv +2h@&{ƎaӀan'Ǔ3 T0Bhs=kfSt|FdY|Bi]쩿ҩb/6(g HvļZ˱m`6b;k md}߈ke9$$ Z zm}#@;Ql,!.kZjεntCUۈYm$Q\ZO>PcSiuwٸԓXʱ"mԜ: (4p&XA,[H ;xG#I\Vۂ66%0 Fֆ,OQBTgJ\V,cNOiۏj, <߰go#QAaj7?!caձE+폘y )ٯ%FZ +; 9kh#Rgzt o͈+GD 2KԪ{bح[ +D":a#DKy [fJ`-@3*vSSul$su,ׯ졎jf:or +~En jJ8$ 퐥c#x7S!be݈{?@3 U$>gms2`/% P0"GtTិ7qkY,~`3 +v\g׆ +7|g(ۙqLѷ_YG]ל)#-)죕 ɷ 6,Z@M )UGiTʼnKYDuhvG4ϽV@g]#dJc=JĜiI2NJQyTGDn5[ sO;cLpscݑ$5azK 6q8%(|I2ہY~Pvz6ؑj ׶%ۤyw.d@!VyKSo۩kolhjJ$]ëY I6zvT"qsy kN:5Ű?2݊nޭS(hAYOn)6O4͔Ƈ1jO{4b`ߨNGtMh_|ϑþODYGwܴ殞7MA~Va>rMk4]uҐw ~6|UsK;+f L6\Yо7qR0 | CFoep +!hkau3Z_(BJMԷfˣDzR#9`0PR\Gɘ 3 +i ;b=˲`)ܪBX}p̧x˰--Ba,5_ajhNTYܷ%t +eE* O_ȍz'wvǸ橓9ÄL wJ;ND;#\`S&fO91FQTRaѺ~Hr<8g#2@K `P]pr{jnZi+!^X|xǧ~+Jvo ExKV%MI&j#==-tzyiU> +~']6Mп6ƈ yK}jY[dYo=lane,:<&HXܹ?IOܶ}Et~ +HXߣhQnv֤4YTduӫ8S>*WϯnN1"^#~aNDmԧLf;'fljذWP?qX| ~ +XE;mlQm>#9#/% +y}!'=[yS}i >(|=g߷Su1WrOx>ȣF׆ukf>osMG%7Q@c't=SHk~1HB/io>Dضj6x:d(+R$D-ۣ~kg]$niXZPuyQLUkHNS"2l:pe-Õ ~dMJ$ũ %䊀&KH8Fw( -<,.8?sJ[+z/@|}- +X,lS> P]M2i)[%:I#~!AdkޢfC3eΨ=~/%Bm(&юc|?1$0L2~F 0hxGg^P&@76U[O1FAY7&>FD9rLA[Tq@DuXA qy{D/K% /qs00=':wGqIC(30Wp*#p+b.'~ƚmDg#=Պ}x?HC?RiCd+nʒ'[ha ߣTD@{N)w{}M)cvmW4'%p8pB**U*$$ KP}s=pĕWZupjZZ+?vI7eI +y^&so52FR~D|O?XņgMMEVg?ޤ}30/rfQ0|-G:ӼQK=A9O+&q _M+ٓ\/Y~680{w^o8xb%b(oZE)-A K動e eǑ./yڔ oVYL +ToWJ3jdZC?0}XixJO>Q 4zT]v%FD(]yT0J(4`3ٸbXd/`뽄ޫF#WLa$ 7_]-Y3^Һ,N!<6˕9!sW8_mnKDH:[: 1/i>iA,=W4#Z@M+K&9tInxE_:1^rzXqΜ?L=únIL>Vx#lĭF#%z.&(BiQԧ腢-SwiZQA8`Ca8VL~%0uW3ւ4t%N+:N;[LU?raQ8}p({AcBT b$ϛ$_ʀ N ] +qO0xi}2 -/Tx`k ~5lcRa"!ȸL}f\DF \;\a]_Wa+IeLjRWՙ+\w" ~u'*)ޙ,(0jOΕod/T,CO9\*y7݈UȴmZ_Cq@Cyz@aC;\kW٩a#f +=NŻd_uׅ_qؒʼnt\~a,zoڇ]6<딧lU;/U/a^Ea֮Hl>i:@3AM3t 9AT;Meld'y&Q( +Ayb3 2l)^|Wo_ylqp2 f'EWCQ$;@:Ԟ%iS: Hw˧wDȠE,Xz7쐃6&o/JvܔJs-:~^StHB&HôQY4anWI[iU.L9R| ٛ`\_A&=_Z08_˝Q»Qd Sg1VU"n1^!8^=. kPqa*pR>5%Bq`-{EL]Wjdko=Pt?b*i:^Va`/c#|KT<`"Rr#Lߓ M^47-cdp9t3s<0SG,_# Ccv9m$+2ltoD׿텈4w8(f Eᾍ;3UϠ#0OB'VNM%n7ړd|]wyz +R!B>v9/mX6{?ysNNԢh05-|@_;$?}'2a7֊<*,s}G < i~ +pK*FX&b91 o-@kA319wy2exq(+2ŀ)&"BJ,ؿeS^1 DZLQUP#E~ddr A6|@O!]h;m@c8@*kO@3 x:';DdOgoGGݢ*QEu/K?1Uʒ90{Iώ]|*}я߂la7Ej4i' :cfъ8GKK|Cȅ$"{}E#6$}Cnflw0vGI-0Uٷؒ TI1aśN[>*"3efN83O"4 +}`Q}N4S-Uuv2w:;%/Llj=R¾K4Ɯōm j32ZH '?pid`P H V({m`j2TmKp}Y.W@%ZN+ǥDi\>^7i!nZS )B*+ZёRItwrf}> c\M+]n%72үlȇc\NG"%1l$SK-:0{=Uv' Ⱥ'הg (SZC_"DRIi)o1l->dKȅy.zW-{)O`Nr.u.lG6˯dƄ֫pl9"ky؈|/fNN9FG*erݨ̴K?̂hli"qْ1[ Lze5 V!i]m+]kkюBt72\ +(gxìңt +&U;`!D>sd)~YR#IpD `BݮCVZC$D><80B@f2@+o<gD|KW4cv $l<2.{S jXp]Wp$\2K>0PWDd>yYJp>V PaW\r}k~HCOG Y8v0"4v!v![%e؊݇=[߳u1z_^_SQ +$']kpJ$76FL4ZKV^ uPk +`@|] +*p9V=Ua4Åϴ1(>*ڨs$Tle4FZ &4x׭}{G#O!=7DEʵzjJraM|fpS+W}2#2+3~dAqjvijlljHɅgLd'.VK\UyH3.[SUΈRD>tyXVued_zߩ_RCyVS8OXe;HwQPcaэk=d.Z?EiO}Hج@tW '\^^*ˇ<ۋu%|}P&A%Q*-9:G)/ʓ\R z34VhCMK\~ o0bw8A#}vtev< ۢ/#Rϱ*Y",)VmTNL3<2ɖBZ M޳aAvzzL TS/H/{x@t_2#āň/EzCšƬD(oYAt(`?En5e k /٣J'W&Z!w=G7Ox[,NxBjmQ$:G])1==THPIpgc(uDiF&9f/V +oFdqap ZOr` +xWpxwl<S@b +sj ;@喸z$e:nUKj|DWn`6M^]F$.^VVDQn\$b`GH#@Yٛ`t9*9wcP}B=Ngr8Zbz/]b[pz +F&߾ +RVy$yh{moK +4'tTN33/4"B:J1mR͢JBs72c1=uVMNiN*䋜'b,u I+f|y5O*SBʸU4d-L;F ,sqtJ؁~sxb:D}ZUaz#o#WxSC?*ojB7y︹.c]&kr8Α2eѢ-_DviO?4R +~267F9yd4<{Kiun"Y[^n/{)bt:N#ܛ,z*3\OԜ1#BBنsk&Kk=h;Jy@g;DW +ƵD_k^$ )Cv\- [ͪ{/3RqnNє"*k${IoJ~ \DhbjDyJ,O+;* tQTD0ˌޙG~\9?U%uڠ3?rxGiUb@RSC;=&z +eJţcGe_uJ^5suE󍶆ŠQ)|KGIQYp*E@eq %V K/Ra?ꨀFt4Pc:׮tBy?u +r3}J +)23$TyE-t*ϑ>=liMᕪ_8a<֋LԴjQ(*PDkðrd2JQg.SCr 5'Ay;t,"눪C#ȡPZ +*Ǚψҏ(p&#]>4R]Ktm큰E޷/X ^ F;l!`7^^,Q79.WO*4c-IWT#̢St 6:梁Vg?IIڧ .Dcc]u D~)co.j%C +XٽOX)lzMvψ{AsJ<[Nbg~0@Z+^2XSIZvHZW!Ո<{KYV>g%Ev1} b/J3"RH"(`!|`NA;` Wn=c*hUzۓIcE#c0Ej*ӽǑd'3PtYWynoiJzGHZIϪv\j4bdXV+-xoB}ԨTW +Db=7Ϭn<؀}ʾCG[~EV pҪXy H^F ̬@/s}(rp$W"e`(ѯHR8|G +%_l o@usw|bAک9MD7rjqeIcP,;3/˗5{V9_Uh5 + RzhjySܥ= +DV l'p#7:e}G5%V;c&0 6/Mv! +#[(b,U޺eQϏPXӹ +^n!-ꌩO{A>N6%*/Hj@em\>ܩ (/8DX +(E1i9jM>sD *c{{^a=8Ȩ *b,રg1E/Skk$iE!׾?'1\;s@xАEgM5?־7,-skWe]KҘD.@p*/(j#8'Qpr\ɫb-Otv+7VFyuN+mF_̠e^ yn/7mHD}GMlz\IT#?*: +R\(ic5Q$ [2%agzBGwNLWUNzμ^\=V2aI=P,^ Ap@ +[ٚ*5۾Qi#; + >qݗl稿WɃw`4CԺ9| 5+J(fEx $tP,r;8 i6- trޟ&̄G4mSs`2P)KyMP_q? 7FWAWIiu&BImg2ip7w_dW9+tV|*#nGݢ~}a|?|ř9x:7ʇ5q RD~AųBj%; C"WxW:^[k?y^2k@ǗһYnW&3iTop@e@Z[LZto\#ʞUgjXB}L9׌ɋ_SwQƝTbLynC6j>x8@8݄&Ӥ]- +Zpz䯃QdwޑDWՐ("5qLӑ xΈ4ţ I$$/xS @U5旘U!"X; x}y%POjCJǚNc +u0]~8+t-s`Oq8n$f5# ̩LQ+k^/m" ,CKNjL`R~."j`W?ߋb|I@* +M-7 +=ڻۈ ++@ h_/D$U!U =Cˈ43D@^筗 Td,yБ [W;r% + t0:Uhsa+PV"hzFoRjSH")*w'T9U@2zLA~vy,N,Qng=PFt):jR2 3S#;%%;J#_[/a$b܈w>냴3#41m#Qt_S+Y֕.踄D̎}ԃ?#z wZD%+ M3;X+M@qc5q}ppk 1 +iAZ(? /R7rCA7vsMK|^lcu?c<*3o*?t%*-;r= H`z )K?}9= u;>Jb? d(A es8=𼮶ʦߥb%Or ہp15 *`QRqF'~^ka{ۣ|DLJ`oJ+u'.W OMDΐf%}(I+G ʟ<6(z@` tPyʚUhe3R u\Vv>=|v$?#Ɗ?/QQڗp:JQTՉ,a>W4J@i~xWT6ŃM`aIIZ|?K"?e c((0ʕ5iGa.z,ezҴd=z0(8ptyTY/|LVWOrKR:w);y^ Lh>^RR?R 9 :k9ݻJ_ˇοǾ18"SI,O(&_B՘ @*~q +=.=b ?5 w{LSpbuK[n Gڌr‘tvv4)`|>j?Ei@|Ż}%_a(Z;H00_ +mȍ_nE ^*DɊz+`79ԼYDoԾcS. d<>|e/(f`= "SEwU ŒR2Au7Yʮ9{$f]x; b* %ꚬr8ax/n}}iF޳%`o:F >juԣ:HT׏G:vVޤ\V> $F> + <](qT.9=GGe"WYGmЙm;Ee2SA4{P/Aއ*Ў81h/ՁyT5<y >,9~Խfr\( XeA2zvУť/yzKG[ۈŽB)27כSL1 ԓI  3$(ȹB1bvsx/fHPtL:@(b^5LortyLvJ ꊂR~U:7|5B3roG@)AL6ia]< $"~zx?n"<&GI#t ]&D>ipw+5v_5^iOU nwAhyan=QN`_LwԅGMWYoBaK9 !Ղ&j/lV}=š8A}UC#rqJ-zQa1ZkL>^BRAv +d DxxmfB<Y!{QhRd^JU9 p' +bQ3MO!'4mnRG~O'b,p!4) 53A{C*b"4LW1yNv_]hx<{;Qu4GDMR!}{tBl|a :>֦9" Ep`ȁ1€CV@Y1?DGc6cbDs-td;xB~>}P|\ϳ8)jAD0CD<{yLqJtѦS\ޟԦ% >;K= # jl%*}ePUEmz7R' +a1+UȒ]YpfTԳ6? s]w]G,5fR2RӷM>)@ЖwěJGü]Oi0<8=' ሤ~ JޛY)ĀkOaNifO +Ffs45ԕp"J~8O=DksBo2_iצفVpPb^0+ȜEa_iJ:lpc'#1!N;3^ /iw_,f+.Df`rCPY__ endstream endobj 46 0 obj <>stream +X^yEԡ s|s3Zj+DNq.))4XW Z`ACvUoHՉPeib J<z`7pκt[fwC^qwAxhڌSxW9ez+]ܥǎ.}nr$`%" +) Fb0“J =d{NkǧZbu"jI#ʭyyQ-"vonXiG$@nj)FnuЖ@nL| A7>ԅ(KV{;x]g?XpK>\Gs &QhcB rׁP9>0UA:4A?l>=~;u.%egGIm0b6#}i2ꋰ"#KL+@Ne]RIygɌ%z0w97ψ)]nDgθII6+}BM s`0$ EsugdS$gD#`;q]:@~]OOIJaS W0 oMDzV;[$p[no6N aYd:3Лtqי :B=oNgei`>\KM]laj9Eޑ<l9!}*ß? Z M"VQWQ`\`ٹQ2@t:aMQ5)v7< ZDtDU/ srHY_NpL鬯ri\&`FN9JyQJ8Ì6.̎Kƛ4 +H3?V!8Q0Nw.Ph kG:cW{3(+$d<_Ee_Dו^@ n"podygQLo&Ps?z)*?+͸I=:j`}[`6蟈R苁i8Kfgu{]8l"csdJ!!NN.O35y5:i~jD +&3C@E՟b ߗrBQ[Gm(u <Z.: ( %`Wx۠g$: dG2;%l֓F& *=둹4ɕyJ6"O]|y`U/`\-K^љ+ӹg!=M.K}GF|gbͤ BbohrSfS ?ҷ?=owk|qRsaq[C&?ɩgY 걮fIbx%a'U:lڕ:?D ~p);mfL2Lu_aWȨM7ʕEx&h( byަQǨmO⫹S5Q|S zxRU|B6fDyש2LMD+G#QDPMKQ̝{Ȫ&h{9"WC\0,s JxcD=䣋>ZZr=,qD/Z",BVX.|5ɜ]?3av.h = t!_?E1BAavAG:qz9 P )SFD OxtiA$>"_@?( U`j8N_j0'%:|[nV'>  O&AbeV)YmEV3z/M^wXàx; [#B"d 0ACKϺ3W<꒯+eѮa`D%(iddZz~2;{Tk#lwwUuEgoِŰ_ `@ HȝտG@yk(dkłׯPLП@q$i + M6ʉYڕ>J7Rc4R,{?QZڭoGXi/z35q/&30,ݙ]bf7!Dj֠XpSZp}ouuG>ʠZçD;0C~kyվPz3JuuPT3'G= 1|WX J +[Hp;jJ೐(ꈰa?g@)H;rKcoۄ4y>ty{KRn42#{mN~8K` C~A+Bܗp} kT;ζJDGr/ ZؗAGPr7"gԿʾ\yImIPb@Zc8^Hο3-$w䲆KK;gFn<3x$v% Ӊ5@%h{H,vB̀}$PjD" \Öz +,ԙ}82%[`Ge} NgAIv o, JS +?Τwr +^|o!SYH4uO,mU裝}bͰi[h78Dq_` G^j2ϑi K2?VLO~9Qaϧhjh)6VyĂ#+\o.%h P~ +G#p 2˝-鄧$Q:/E[3U,X +QpԅO60y1@`whL@#ҡ1}K Ei:=ܟnUSI+&`8߻3 +!&(TXAB@)7ч˲@>ĺ2NW[0F{geDpoys}GD2l g[|"5bv "X{2\":3͘NOQBi㈖/)/[&*;0_ ^(' VVd0|ݤ#KBCyj=lXe w.ͮZu?ze@i@|PtӦ_d_!swisNjۏS+rBm F,R9 lmWb]V 9“V. j{%Ilù_t~%' OBMGe3s9^שY"M3*ue=Q4C~@T%ߡTũ~o\l/DwAj'5y?nCR6e~!ŋ +  wP6p 2ŸZ23Mvu_QJi +%E`oVvpIGkNo ?Ix-gLĬggQ:{T.P$.!aws?8HG yq12:v&~䃴#&=.϶Q+\䏋A_<dSd{ᯫgX& 'Pݡ L4`B]+O,I>l0F$܎%5Պ4/{?Qy;+P^:I + Q +н3rZt=J8'bNR^41]A,wW^o^XDu@c0SN +l-?c7|+E}a+}!/]2fLS|3;.ܶ'_;sS~?PxgΗ:0/zjBSNz48G`P"8-e<(o|i+Q/qA0 5}Y(;mQc_rHBsL}Ul42 +[[;}_#sVnX6o1Au10|z0`{,Tͽ6}G +W?R*JL/mD9O7u0ړғJk|巌n"r+vzR VڅS85fwWrѐ!iĀkD0[Ͻ큗2t< !cRdQbNvJwHƞG(:WI18O} ժ=F`\\PG)&:^_y +ݍ +JFEX˲tò$EYE0Q0EzB A|(~ ?A2L W).% LwԈٟ bMWz5oQ`4PI_>O4"jJj[G@Jiɲ-:3B\@֧0)n#1> ־nCgnASj(uŷBq0 +I J{}4|f KTKν HRNT7~r!IŽ4~[iH]gB"PNcH_1cC ~Ws3ٛ ʣ +Hwڤ`kWyMF)sOձص7O=!g{/+pAb^ +H{e.FSN@ǷzQ] r%JW -氝# MٕE2!|=WP׷6V"Vu%S,YM{4Li(\V!o@66. F s{xA3%oAϫ7ZKwbyyJYDG"׬Eñ^ߏ?pmW*ߑ"naIa$Ċ>t +-uZ) p1P|gZYĆ6>d/oՎP%}f^ݥB\š7cz +0iz])z/.[@#n7ݵ#G9,r౤RZBb@"kACk6YbRMJ8Tv @OKbXȯt|z#l\|v=~i74\( <seHW1~ CsLkpbGPpkA4XYGǒK KaX +,ˡR¥8 S + ;xgO|V{D:u%RɾK+;5)QP_j\JLwꉈJ̶bR0"W 3K\%WAIؔI,:skDak4[GjkN5אxs +;v mx/LDqJNC%?I sKrfTK΅ ^W<Ĩyfn$*g5[9xwHPyQ48y+}Y˺Fhƴh<aƃ6U4!cdr]Zp)|TG.M)>S 'Gs9-/ʀ C&FkqOhgj +A5͉T>8i%`(3? |.)uYuJֶȫ(LDY_o0\Yaw4ܷ!Hrףk2p{i)DaX)YEF~)HyfIܜqV Qx[}77] K}B{ޢ Cӷy2UA5d?W'E8eZQ 7h! kDHqWd`6-~3q&=6z5PtB Plbr*O54C4?UPpXAawt 69bdj)g%]u۫ZK2sͱj$0N|]*lÏy/!Ž0YP[5BapOP&~ #H#Ǔr&WZ'_F8kB|P4[}/3\#B/wXf)Z:Mb!b&_\Hы܌dw8;+ ?̮.u[aa|IX ^^YZTK47dJhJpMDQa5#b~FOTGo7{Ƚhq;Eޚ̦OcՏXҨTF,x6ԝ>@]ښۙp>:0dϖ}u- +'ʤ;W +/LѺQɝL$'a + J:-(ЪF$ϑa&hc(v-[R?Z9ԕ|| 3(u)ći Uj`֠t;8c/j/5Vr7,z,m'n0~EyP$~_Zw佚""I`<A":wF?ör-YQA: =6j&?;~C!Cw lŲWŒ.Z&M;RByߟg,:fBFo-f< C"Mj6PVDǗ?rQVfib; I/2%E軼?˺>=>Wb sO&/ HACMx<81PG.:k [S\Of2r֩sRY\_&;d"8yH7AVp ©Hm!{ +B'l=i@L:~;*z+|Ywt)t/ZQy\Ez}~P:&M#rjL~'jf; ƫĩ~|"*ϭ)"?P"ݩu]OXD -u@w;+.u)$>`4Ziۗ +>=3!4 P%Ro,FI.|L,3Ҭl1dIsO/] M}'6!MK]< x|p<"wl`&t-=j~Z !9*C +!^afI4=J݃zzL~g( (ԍ6WN>AdI@ +w!* )Z;yx\+;膜E=~ڼa3O"Oz_(E8wUh& Br}ԯx+EOu,) T>8JцX*/k@6O2áOW_(36X])l(,W "s48rZ,-! $DUe F'X4F(h-$z 1v@ڭ)e/5[ȅdq6 +JUÿ~(q@bcWT"vzGGFPJk{'zpf/ +bFc֠."ZW=%Wa~KP\ʼn}ORGY {53tt;G@O*~;d@N=%ڥ9U` ,łA=wlK$~X7#}ъw~a_*_/DiF'1,qR̝ߙ~Yt{y~4ɢލdI7@ U.z LМPi L lGo:] cqF9{ feE0Hxj3_Y{ce땑eV7tDi^/(u:nMp=GDo +|;b/g` i/؄,p{wkPUl9gx>>A3?#@FxKn|M8R +n+NaqCj#WЁX«;.Fh +GWp}D^u50>Ǽ=+}Ƨ,P|>k$p!@M kJTD +B.B.IFXϑxZ!Q=zW;4]cW26N +vn}e9#ԣ= ӤPpz'NjU5(@/ʈ|q= + 8PT7o{ڬ1goqUDGggԄ#:>Ǒpg3@^_*+m6k8$<,փ!q88Jaw=@T{_uw8-;>(v$9p#3ݙ踔k +zU`d?V%BHd|WS0diqB_"KM ;G12~Sv"kaBAF9kN$ @8{՘_#6 2Ӹ_lZ0 q;?DL>迃P ;R;u;_Eձ=ۨ E !jB&:m&s$]A4<&D"FE&6QVvfJ8T=p&;j]Hьב&?X}X06)Ή;ralLw:ܾ%p +0=\k):'yݗE[Ա>,Rul"3Hɀi-= cl,1܃&P -o/(D6P1A%q8md#VۛEWPW26܏l RCزA%Do\=IfSݏ:IJF.d8x$~ǚY0 +kЁhYt)/jQtlzw%h `{kqB̊c@}Wx,OgIĩQ E}CP̬b7Q7`|V2/J=K&R99bKA 4jCjŒ9MuY i.Amu,,a?~=IK.X\G9[ʐaS̶ T 38r/%[}J=n:MWѿ◍#5/r,g;Y rV蚨Ӽ]D{m=J'MzvA%V#Hc{T!߀Gu9RZmMFͭ]^V\OYt~`FD]_Ǻtz(/P&qtֻ/t=D7xhQw _ *s>6yO𒜱k)m`!@m Е~zȣh-n$CEnU&jF) d=(ctgZ,hɺxMt_˹#j".򤚋p-> ߢi0[E}oV6GzbZZLD/OrUBZd$v8Rg}R-h5Qϧ>8)LXo1^NRg}}u(kSMW7>tH;PVz"r}:J 3R( k\QxC4E3jR]ZINlcET +Ѿ|67-Zy&nj^*.g`d "pc~0yj¨t(lWTBE0e@(/Pd-0&ynM=B2U"bfq;hOպe +T46'66XEƝ}eG?4j2"1Mմ{9- += o4θC'E:4z,j;Wb;'mޱդOƟm;l6 WCvF a M q TtS3r vu2zOX(:ߣ Fvri")MΚEld*f#A AwRv<55\ǎ9HdJD/צGڴ9$Cߡ6KJP L7hy H"KBViF ]g&}f),XyqĦ_tXO^xkrŜ8pS/WE?<r8gQȥh)jhaR|u7O-'+?5Nm;p.ֲȻ&K?&(H#g5ѧ%>ERjZwisCs̪|ÜgOF$j:D0ãnaˉNJ-lI?`+A&sХ eɎjf-iDݪNQpwq|F촞,D$ 5!iX?6 0"hri3csw_ׂ}&MN2r*1oصv;oMBCDFzm1M80qd;Sgn܉XCWAV}z{iryCP~9C(L`y8Keb#gdDy}܎:lٽ%azˮ$ eΨȠ1H ,)_G0No,udj p-Di;kp<`5긻x;uES5$'~^k 6"0a!'}I-~49K5mbX$~[Q?&n>1#9SBQ9f]P%|ATСL us$p]& IKG8BưѺwYJ9L_RTo`ZXËpXC _aI<OlX?0"u:ѬZF#6^* ج b`4L -> + l6/"uLhIq]`SK=ly-H8=CHsͼϳTYQ9Ly^/` ].0{TT:#Kr@~(Ɣ.K-,X[5[lsBUKk:oǽh"uZ4um7ih͞EoQ6DX[s{;eo6E ++/"-lZ.t)HdғlqN@23޼Mr28(l%?m(BH q}Ί;{RVxcQ +0[W)H}]AFʈ<5 G [D&3XQ5E|I?lNi:r<M}nN-[ | -crxLwqP(}urO, ɨ˨G wg.\σRɴ?֎^z\wFeA peךۄ9eNχe]GRhrېUSVubA z׋9 Eڛ޸qj?7VK~h8U&б? wM3<ŸJu9i&뻦|Bߟ"MU蜎luǒe'42oq?@#%qh8BvyhB0EPHUڦN\0vHz/$_`~VF0jߖqt"Nd6!95/4spId$BCԐse'ddk>h(~xH)*|oC7ƃE#M[Rrc0ޣ1E&&-+[*`/uGXWQ$%Tm5Y/ c$DhFCt'&й$PO?˚ف}_+?ӟ珿7?7n w?? ~ߠY|MUWrXNmq=jgŌihn%߲\:P_} |R^=ĆveJFRk^hQU5ū7UF`g-x^YϒU#y Wh?N>kD5MANbzNWT?w{EiERqwU4r[UO2QibGՕmM]5ïW22Pfy.肬[;tT.xUalEV>Tҏw/XwR$n`TQ+h$gQSBagqVcFZV<:ǵIVJ""]WG(Q|jtcpUʹú<-R 3!:V+;{mEF|ŨUNT*19.|}ڪ fpϤYV<c$ ȃ8JRo-qm JAvq=gŊ>=ly}o>hA)&n>G`cVZjuɢ..ׂ߮@9P6ٿx $^??f&Xwa E nMĊjqXo{ ǫ`(b#虈;0ׁCCqD{NFd|-DJ#0atth:қ@a%~k7 # h_σx6ѧ{Fn'H9 3?%6cؙoxpa* ~~`AyDJ!`M^ݷ `G, t᪁O +bav;O;_L4M?2K͠F*1 +s䃺'Bh(\7 +`F}" H4i]P|0˖36-ASi"\"Y6,&bD#o"~]I4I#^ \OӸp}k* #4"@Pףn-V49;e|%Po{eޘ޵/ I3 "tjJSN4Tqf\v-VRa5\lDtDH $-J@]f@T-fT?wxճ֗&e[vK$ӴC9=ޑDrX~wtGW&3v>lGfgsDf~ŔXfDl|9wx:Xv%*2| ,Q@:Q-$Ro=#I""fAWY +u,u)'_Ыm'~Kp,\I(M5f1*}St\Rp=ҵl?lw^؈1L"G(SjXl-D@թeDSV7wEP>Ih?=EW)OGUz>t3h r + ,nS$;tׂ zCc ARΠ단4>5$Z¼SoA8`%=K't; HQH^G")#C.)TqF@:]QpˁP\)Ӿr_ `L@( ?7(ԓ oq&|"e#@'1t?l7I-*XyXUTk&ޮhIhnS9 r=` {nVFQ$:,*][k*Gqmݬf f{# fHiof +FFaNb7azΞ"bPؠd~v)n=(ـͤc4~+ ΒL+fFʢ!^P<zNk0ex{Լ"V<0G\/y Z-京"{D3Ӕ~E=w(BWP_F5-Yl(hx4ΰI+}]gklgqepX餐nܜ؜[oͳ&R@x25 ג*Fۄeu 8H^HB YTֵ}E1V%믁/Kue>x@ rӵr>ցpc{\`ZiL[<6ԇqi&FgUu qNulpF;RZ0po{A#8$/atOfd! u ͩ l [䶛M3=֠ "LuͫxMVVȏ_ϖ1| d98Lpf.#9/r Jm/2 /Buj0׊$ZOʺZz>ֈЌaH]qi^w^rmS4L1GYs}S ;ck0 ٓ僾Х֝ЋT:Z;}9C95{hH|hN5Q\2 +S=_W\8ug-`)F{#i:^)n1ipEb`D$<Ϗ6p3`n\ԺUvYq0s~OIŊX =@q\_$ߟۘi )eaE@(p7g7}:>+t,5͂0$S"*=/ ha[xE2AEa1 ڋ0OBSaSSq5E Сv=uC# >g 4tB:Tvy!+}oQ +9jk}ZG~%/ڃJ2ltGG<k9>.J  8vUH+Q#J#qu_ ~| ;u$ ҇(`;];?;?3YĂz9jWI7QsY$x 2SKPBf,yxoQoSצ8:4_s\64dNDj75iG8DJn5 nL,kEpʦ v9J27$(K͙~PL吗3ID_Y/Ea53CYA_Su+ӲDAIa xF?D>A P{W*%;umSDZ$'GI{ +q fS-%<2A"(D&ZS;sҞU2p@vhe7-jW6яs]A{$Ђ] φ׋AW4 ]V N +g0dиN+So]&xR?YfYeFf9+lq:mZ} \`^~5`y2P(e2{{TOԄMHNפhZ6"~%)vfs^T4ypFw]#0v#m +J-5z4#jJxDA.J:FRxC-@BQril'+IgFo^v|mp͇֔Ce%IJP!jV3k1BpNI(ϭѷֿ} +"'%wGqzH mkJn>I:m[ބ;uGLTGJbzg,֩̑ +YY BRs}kKԅo7^+QVԩO#^66(6~~&3zn ۈo$q "FT}R4NԾr:WX&ؚP-LUucDc0f]LszǰQ`B]_ YoawP[Dͷ}?P@(w^Qbզ__ {"EÙ>+<\RQ;woP=a?RT).J1gS0QAqx6*{ц컦I=BMSM17=u)i?bf`9@+@1TczxuX^s*DOy:@QNZyS6"ZɉƁh CIν+@bi +:m}I~dӨ2 E^a^kua mi#[>D.1__D!BB#keKT/듉ۆKUHk~~aoIe):g~@8PL` 498Sx3ҿGF/}Gf:A2XN喻xM ]5PdJ6$.WH`Y+@wN5VQ 3y1̻E^XU$ @) >h#L*IzGW w*UA4i?IU%>#x^Q #)Tn #x1[mDD) TOPѭ9܂}$A|Sq+))WWָe~Pb)XS(% iF2d莥bR 73t$GmWVTqRY4Vܱf/.U qȓG7҉-.iC|*#" YWvsCɞ-0v[MX.DQ74h ˫ ֯O2F"1D5)z}lڛp!I6i*}Kk,Zk +ۗ )_& +[~+$oR} H#g܁loA@LU[b~t?DQۃ{HmQ_1X{H}B(JZh/6?0D}EyUXT)/_ T?ޱ\*DУ+N/mq@ft; 輗G:/cFZ4]8%L y0"DSuO}^4-@oHح^P3o(-]*5(8e_Kir?HO +%RD ɺ}ˡ#r(G4Y04\3^2x/l?,6뻗8e뀯|G5Dv + AS%!ω I(q]/:6 `ӟ-JXÛB΂@RtThm(6.H7)#1\]A=و`SrE 8eO$j% l-Z*.ӱ+1s*2\ª^@:݈xV>h]qb$ عAi2_sPjhZ}MwcC6]`,ϬK8<\QAa4 ^(rc7’sqz_";bz׮kߊc>7>/h=0Е8"9 +1Q.܋Фk>'czHT  +1)^o=JbdCCQ>nʶץpZfl̨`ҫ8to5sŮW*'Jv4O0cC8 X3ZF<-dwI]B.C$*t=7 Ssqa oҡDrAj߂?ٮ="ʖ֕ e:ʲGls/Dv"H6(dC4xLX x`YiJJ;Im\ ƢFg]/$5._kP=lL 0Hnhnk-{O@:-/U6]8*t@]قqqgE狾EňbU}AzDP^cK>mT'J-% +V&Is fc+AOD)M2|3x^r,OSO?8edB($K츶5Lf QHF*%'Mdn]*N&Tѯ9FVH$ٙ69"y}@yv., \!Fħ?)Czg_%[!MPP"M=*LwTq&b4bN9'wcw%#?TPQB +)9S^Z5e5tarCSJ iq_#$ӂ+4SHX2_xS^Kn/G$`tɎRC) Ӭ[1 ْw/i>$Kn: IytMm3i*oU8ͷwO1 OTQ*޲ jQYOS.r>Nd@znhֶW\ΚH;I0`0́"i*y{2[R`8CQrͨ-}_՗;hƦue#-IcIyRlHt)}e0+MϤ_{LSld_ܾLELHp:_T Ahg*W㔻rQF%CSsR[P3Mm6,ށCoucP)>.ݲyx*A(ꊋںKv +EhA#-2A(YHb=Q~@:W,JCPzS1"FЎGٚ u@t3UD Ծ@څI/7P RuZLc#xol5p =i r$x4=Aknъ}׸|弳!>HQ^B5`BL܌*a4S3rY/*V14$94b_$Rܛ`&`3*s+ +ޥ*3B@0P_%C3]k$XR돶{+ml3>Vxom|PSPR}>Zl\ +2g5~^'&>&^U&ζzlnGzXlAg}w{x?J +ݡ z%g knM%, #Ҭ":EHO $ck@2[C?:5oю:6+,$m1QE5񮏰p#wN~"7Ű=]-A#}b^8#n&dw%4Q!thWth2Q>T}gdzbRE5o@'FNp4jUzBȔ~qΞ2ŻБ_'QJPTEn3/`*:6%נ+ނۍs!Z#j+L#gڎSaHA˟o@~ n&79;]V ᨺSIaJI'1iza࠭cTZEݱ!8 +?U13H#eklSO2?)dP^+JRC#86WEu>2F qM7FJ`(wZυ7*Z~"O]V'>\`pxD׎4(TmH3m~#mL&iR'J `MKn?&@>f /WtLUIJ,q1ipzT.Z͗jn3c<.?ۈbӳL,.䠋WhTHq".c;wet+)3:aӚe7L((_B9 ^(aR2ܨ)l<iMecTz̈\3(CfiQN᧠ +^'ᐶHd Q^ƉRW3_ Ga*s5*#:*LWBxh^XaQ\ИEg=o0P_ qÉk] $ `T!thz' DY )+-k'$V\,ipܮ"s5"犑T(I›ُb'+ oT#UTá +C 7*(K*#Oĵ}TY}p}d.EDzK_;BsfU>; ͒gEYHuknD,^KT/7 }M|GPykpõ\27:a_±XJXQB0veG_0-ܡl6?mv*UdL=;14ZC#WSnQWt͓/vP28MdǾb +q@~OXK" +\#€Z6Yޔft(7((RZ袦sw8`Kk%w %94mCb;=&xFHz|b7w%L5U|Yd1v7qX~z$:ʐx:ՏtQ]F^C{/S{{LrXkM +݈@3gF7JoZK~a1hRiwAXϬ͢yvJM0J,o|uF?p6ݣ bh&kBJj{x~0o jSU+_ҵd`[e^-vU H_FY}HġUC 9lѬ=JwlRxߩh_C`ҽ}XDSy >r b6AHdq|hL1!G˚񵱑aYg΄j@iAq {@ZL&3}=%ֳIq(3) +4yIGĈ.Gt0܂ &awEvOP(@swݨz[bl9:F6 H5D Rq%JdiBrWm *9ZNf2p;aIQa}/~1ւc3 +O (HةWBiPN4nNkݽ1GYc)/eKZŮMFIJ+kqK(y̕+7_ 'h&r%:ܤ.tRwmA(#|s;̕ll<˓z*f|5J.U`ލ2,~ʴKW>y ߔEmJƲ;&){wʁ4+~ ; x )"' Zӌ-fehjY*\թI`ܭ"ٳ9ϋTţ vCB*x8J.#jMKu bǎW)˙TfMDAdG^IA/έ Ц(Ϲd]/ڵ4bZq1Y67CܚX"KSP=$'n, +mۿE o"9y!J@K5E5eEWEk^6 +̷tir0NJ+IZJ i_q"[>p6@sɀ)Cuv:ht=x6Ӏ7A-ۉUODRg} +0(p>}?FOA[PJ)km$쨇f=Z,A!gy62y_~q84OĹ#6 +՘O|0CǑOo[^ xB`)X1o5|Yw?Z;u`E$eѕ#5:4ǙFEάՕ b6$~8J?׾am7acٖ98uhuxWSK۠R +쾞.*)pAHOĩoؽaV4Q +F.x\yNZ.u'z?}؏C+Z<9kt@ϩ)E.hem3;P)RjtwEٞzeab)f6B#+9@`hZE*M@ ڎjVzu)xrqr +@z]d~F6oB9|P@I4!/Hb/H ϑtr<ػPDIؿ"R'߷*xMkU WǺ#\G^4(ۦ3JU%vKf8ɮGVt3Hckqj*'hZ'=%pӟm(*OՏo +i^ek:h=GV)Ÿ*Q WbZ8&/3/6(_JV;:܂Kr{`E=ŬzЉ29[-pO3LhHCD]3a +)mu/ݡ3>1bU /zg˺m]W,]sIJo^ԍ"Uoݼqzk.L746;P|48U& +&kPU|T?/@Ҕ8Bm ‰qjB|TJGQ#~!ݬi y7aꃵ|$^ym(<ϻm:et)AuaUÕ6umrCyǣ=ȟ&3[fWmh?TZ*䥺Up@֞:=5 ܸ~"#r_auyg}v.F^?[xp[W&ބڒRQfj}#-̬Ȋު2VAה4C qYV6ԕE &.][l)3hF5mY-Z9;.u+ӟQ8E mx0*5=?A"AmA`tnk Жz` + n"DL`1pbN|3 u/.gŹ_O#XLJ FbGE +ۿfj Pq?*2 @/6QQ]yX:̱=9*"@"8x 13E&7D0Hw(ҵƅrTX?Ԃ}ӕ_9bcJ7q:KK^ڠ[L8GTj'+7h'-]ʝY1:wq BVە $dxmxel."I:G -l =MӚxbp n(QaX5@M{)h(qi*_O_ }v ̈r嫼0b+%@Q׋E=#nQT%I_`=`P ::m#xNi"/yDX6WCM0b.@ Sa|tDuŠoץp nicD!y-³9|O魬‹֛Ώ'1f{\.| }] +^ +*n +A0)_?ftTJD{G?mf T >D5WMS=~F"*mqbO~1ucczt߷?|N O :'CylH:D.4I*pAHm|6~Fsen1zveOۗQz*Bӕ0*a)+G-;k?+#YݟFDEW[J _; +hUok <DAo` 1@3"UZw۪  1.ԽEis E+SdįT:R\Ŋ[Cp +dˇ,(qHR]ɳ2(IaKT}-{fiJ'=Ƨ(NGIFlZ1|`5$WutFƈHa݁ +(խ\ium È#rкLW-8"yGgѷP,.(D|6ߢlzV5-7팲zW/` JlJpU~?YI;CY6ߜ:ca7kCJ;$.5HF~W$rMn*mu֨6U "+.Y0r:yi OHyq[|1↘X VRFUb3Uok,3]3;{{+c{Q%^g:hҮɤ { QAP9/x0&QS֑!)}yy):o{@s?` [%I(}?wgU<)47h%ؼmn߶ޓz^?[ 1qh=O3 ro[{dLj+=tlndoaH3Q4b|yCJ~"VYyn,C$ԝl4n6]Zv x&ސiPw'B?@wT}N +wy[ڈM/'7Z?=Lftu23bՏ:bP=٠BjanV/"rw5?[ +hM=߿Gd{üAqi.y'>_nEe`> ~a?DM5 +ۻNTp;#,`oG)o0":=غ{ëV SV~=F=8D#Q)bM-GQ[82xPt"f `h_ 7h5gMmٲRi:45@{U`0`1Qsa/\Qwɭ +n|} @[Df+Xxa7PXDV.Z< +MGZbs?LHmM#^FchEP *R\ֶb +iu)f!qPxIW19Y M'(&\.¿oAv+bhyoyzb@_>hBGI +ZۆQ_/ZI4=>HMHьmS_ H쨄GJo?%v7bpvGU +k%,Rڧ6 v0+뭱aHoo5MQixۇ݄0(F$֌)C+>Xj*+4vAi+Ʒ][3NW{ +޾G<= +ͥe-dlI* 65K.IZH + ֹ/=<)}8^jat|y.&²$ #t+ +r쎙UC!̛{ mz^vDac-MH@w> D_t)VU2(^/(o3|w8#nFoGN!rz=Fs Q[ +!U%Nfwܖ~G`} 9"qRg/6$řJݰncW諮 'XrMՈ{ZDrrő3R> %5#f"zCm~B^LtJ%&}Uk;$mŠkRhзvusʚ?7!I awwG~$RCI +{i @4N38k"PhIt*4uirIbC xA +i ORRSs*o]&cوX@:mon.EV,J=sUhLMWS $%E2DA4¢GD Bɣg;;?]q`ǿWǿ_?߭P??_|P +u|EHqз>5AGss(w$GʸV +ՎŞ2M:CSqGгCZc6}8PEDZS4ɆnxlYj+TPN^U шb@' +#$+!BM{ +孍 ,(^8[`)UݏE552,7GB^A3 47`opP 5QU(S-* &ƅ(@@k'mKZFt xO՝YzpGF0X,GC4r\DԺJ~kYYaCm w +jH= ;hix=b=a>U>nA%gPPxɉ^U  +f;ugO-􎀂Wj~bÚ]rk4uZe⤇c:nE+·o{ .[r M_4:e\U<*?A兣NHؑoA?6- TpxtC?/$?Q"cbCXbSzmFSΜ 2(jK ~Zf5_#ꯣx?h;wٲ 2 pA"b*xz讬޷o#˲B<ҵ#($Hւ@+ZwB4_Tʲ>(es#F[Y9G) \p*xEzR 8a'c:ǹvTHO 8|cj)LDq +YNDgu(8$i +>E2}R]<@{e@#GyP$Q\h 2LOӀ ݠHHG@ʍ._Rjjۧ~AslrlH܏ +YlݨN-|zh8z;?W>>"p'oYp)Y5t%p-_ZE}b'ҳԚd'J# ]G'8Gzk/wYTbq#rZK?Ջ!w4$PcGK(`k@6u*ȭ~'8B_mMHխO"RI@Sx<.Etq꣯[¯`Ƹ H{nqthVŻVl> еp]fXD=- /tkz&dPaM*&aLP3j8>_Fykkc ìhslS_@..}%e}ꦼɗ08~ހz7U߸3BI趣^Qֻv=9SCoܑY]hʳdʆ|fM{}y-t<ϊaZl7 kf32w'C#ڴ8r`r/n[ƒm7|jTKgrwIYL6b2ֲoA;*\m +S,WYX(7!ޚ"\B(cR M[+[e ꬋGp= +Rg]q ~fEo5Z֑>.#5 L+Ss8er8lב@2qdaץ͖\ꁱ3/*E*Ov?[Fxjvjs b*=;4`O(95y+d>|-1)uT8Z۝'ќ(x!ü2[HƔ51'FH>nDɞΦ陉bn#'JKĩ)'IkgGLH=ua`%)pKh*d"tN{ۡm0 XϑfB!/RozU}Tsufƨc+*DQ lJ'mǖ@:]rPt(Tp&3jDؔBǞgL@jsS3z1G% \w:J)|N+'4uyr8\8 GDyn/7V +]ҹ뱵عRB? Z#p&AyIT"EuYKrئl̬A"kBхJC8~L!="emĕz=5 11{/SU>5@KD3 OҚ*GxE#8r6s]+IWu 5 4;`Z퍢3Pf]s;Zgo!ׂ^~yId@#|S`^4 -DŽ4QF&"PZD_(7VkFLOѽZi81];^k_lGTbTr4 jk(@fԶ羢dH%B/9P{W -*tWkEy~,f[Emү-"o?o⪃HWOCʼnOyJQEeĀ= :L`׉ǷGY2ieo%.P/}FȔL>#+}x8 }XH!`Ͼ 31h ށ7C,9?Y*.KA^2."$ 6jf_$ae^gbC%"Glp{>sDoB:fv>D[CQR݀9(>`Tw)70*01/(8y<.~>0DZ +#_eF̆6$:o鰞9܍_mծ<[+KJU  mWK։t<~ۼy>kKȴkmu /\^F ÄJTKJ g>cvwzy@ +R1,[yWu=/c@@HbcIHh"@PJ=0EQK.zQ՘*2v [(|zMׁkVxݛߌBbQTjH=g_'Ogx8 UIFZAon)d-9T`**]I 򜬇Z *Xȶ-yTl*+.YKץTtIpN% 쒻j5)2b>ٜ/FR]U@b*|ߋBA'gY4 {S@8eӇcY &h~QxJox)/ԣB<$I_6C\a=9 ">;& Bv0K!5^ J@sJ\2BkHeHN1* yj'$|~! ;}Y*`Y TR5 =(S*k4o:9o]J~A+PuS:iMږy=9\gٛa qM:&5" +tI(@h]qP#Rg" e8 +Fj\byTi8DW dz1Ybz6*u7}ݙ] =M* |nl +ha!)nq|xYO | jGՏAmP# +tUph!,HG͢kbFV ;P.<E*K;q52oJ" u\f~~ҥGT&&pͤrJ&(' +'ތOt6p?+yZqǃBIP46ˁ#Rکאn&̼\Բ$57cHvC@saI( ,=R%ql%"tL"tl(lۯ$]Vݏ?'daKm!fIgcQx@ *`*>Bk}D5?9oF^TY]! +ʺEUW"AnFt{"D xW#"y$1|QZu~vEA!ߌE)s*Fi2(c#@fC3r.MmėR?-ۨӑn7 JljGpɹ-:/ fE[: '',EX +чܜ +|/ +W`uS+A ٔ $"=fSQʸ7zzB(@3uN##œdQ|2/q8 qbg#T\WnN՘0`vG "xGs4ށ:/jB_x7~h3:3,͠ VU btq(Sz)[G0W8i%Ȓ0,3>J#PJȯ9]܎u9nzUN(p`#6<ɠ;G*wNt`A,giN:Ih$r G0)iF[7r@A`j|# ܟ*g 6G9:~O0N?H^Z$˅uD¬9sӇ O$SEҟsZu*==Tb)5}^"$P(RsӅA8_$VWVwD(rio<\wۯjN|^:=f9M[T؜P"½w( (D7DQoH ܔF`3+3O~~m04[|?GC 8#xH+9pOViY`-?%$pbNcJ-3 m#]([F =墤wUXM~NjC#ѩ ZUhGQC +J4At_S^N;hIYL0剝jNa4G».r +((ьi#Oê]!_'b3]DovמQPx OCweQ +vԆ`j!5FaD d4 ۨYzD̪ޘbv[AES/&PT59YR Uú2IrMg;jkwZ{\D8Kԁ|H~8:EV)&Uب'eqTYQE#| fIC&$`:U a) ~7JBE@ݳp.mMD wlIP 9u`6+kmS nA!M#Za6IGOGd~zJpDP".} vl%㴧`$ :6F[2 +O?'ލHpu.8=1sȈ.!TGCG4`IP)4FHT.0A);Yl8]SAv+^UϬ6c8ieIm9MvpCTTإ[=]ZȀ1"S.MzM9;)]I}&**A ViGGm-9.u]Dz7 lOT ,h3TPBgo@;Z*AYك-AR4\#DV+A\w3WRme zVj+cD ސˈ$s@iBJm<<~"q,.'k$i \ˉ+%DKyKЪͬ]h],xO + u2&Y}u)SJ'^-uu 1?~qCR7qZ9I}U>Z4 +8Xȝ"cJ05E9;HW$qs"(grv4GQIW`zF$Am@NXBzimT"8R,A եN:N \Q'PWhGAAw]j-ܓ ЄjTVڠ@^(:yV2[ +DTzBcPb$w^1LOцLg𺎼wyxc/}dQD";֒ǣt ,$YZ[zR$]~4߁tt":=U<@}2}LL5u-8i$oHnx4 Kz" ~l|.Kx>Ng 'PQcԅ!0+U쮿&ɼxL(4!"e$u "!(Jj^n^U&ifgߤ&1K( wz27ś9IIm,ICThl /FRI8*HW46;r#w#!4t1b)i_)D;)<{]MLJ4J}2{E8EDB 'uҏs)v |l)2Q4^l0Mu0g/\y9f7"lͫ<1#HQr.+:ZEPS'^'x ̳PKUh{Lj T +u1 +&hn› 0gNJa hF!$j<@Gʻ;@Rh濖 D#E#D/JxNKDWK*rxX +@Q:txUO!E?T`ڼ0Tu9$LU:,w8H4 +&yi;5TDH`R"|Celͯ.p Dzl$=͵˪\8T2)!Av% ~Z3pioݓ$ٔWBARsQX!B\3C|E~jzD{UGa]+g~ AM)i*ArǐF۴:DPt9:%QrI\? V9Fp #>Z[GV^-x}\)նW4 DHyԪ~q*4R;A^BK4@fpϴ"PnJhtI[^2VН SLu8)b04yj+蒵q>w p Wonԁ]&@2Yc=Fg$uRvtgi8zc ]՘<Rӛͣ/v# DsAɆb5h/A@@ksGWgG`b;5%`Գ{"FS-$(`[~Q$pbY` +wXED ݓ84!O ]0' 0% wXK(C'GI H~yW)3Q)!2p,.K: +JI[ HJsH<]X]Dp}Bw՝Gd!!SQEe82]/YExG Wސ`٨:;Htwȣ ]? Fd~58Dӭ@(|f6Mĸ +xkxC0 kN(;obarf#:I\Кu$SIgF$hQMq]԰.( v4Pf(icxHV!Ŕ8)?*Mth ?HNodiudCRz8Ew۹hW&1aUbw6%QԠLhњ>z;t! DEoIsB䌼Q%[G,ǐqEȬA%Ԥ~Ǫwa(sa, ҫ)юOdL?գhngmȔPCSq]|ee$K0d4tqg0PS0JGȟ2_A̬920.;1꠴-[{䆁]wE~^7hzW:R!5/1"q]I@P8jǕh~( " `#Juv`g0 /~!uuʔ@^OCHrd!""fjdDܗ|T,&fX/o=?'6_eۢv)͋t2]f.gqQQgϓ^$.N]{v#PV﫴Ht}ЕaarDŽ2p4^I bfG̏m2?cD]wp#MWHh;Q>[`z3)DZP>KStIㄙ +]ͧ͂ ;ț,4͒y=(4g& <zE9t[S2 Տ( %bܑRv8OF.? `#jZLy}5 2IQK2!Fd\XHz $ߩDM(:~I230FПU?+ eͬf7omZD)`(,BUՔAXNk ++A,[kV#.7Aj~ͮt#r*@X0^;rX9$빴8f!K>HT(!24nEH}2i.`a͂Z'R%0("^Sͳ+'^0 16|.-Kr=M'n@^G)xWChr3}lѯ$ U>;=1YJk̵nᄄ<IҒ] 3`ٶR (I-$ IՒQ%%#TX2(< =G (@pnڣ<-POU +K`1҄zUpJU +CED^-x!5zS"ta(î[|ޗiƶJ}PgitU+5?Fb(y=+og{[ڸPVnN$Rl.Ʒ(cvJ>z׀vq6]Zhe]]iBwҴ f|IMzA 6;A[Gu̲ӱ\](ŋ0p>d#yV}팪1KqC4k93D(Ųjx`%. sBÓŝ]w^3rGY+1mB&u<&һi$REƱSw &j'BSv Sȸ)f9$֌&6^5*bB1>rY](>]Ap@|C.J_{@$#4Bz>BwVwMc4،g#fk$^-x5b:~lb8;aAB1gkjKhX興R(zTHo!jc$r qrF.b)O@*cvCB4K\4EUi+ƫ^ëS*5ו4'y< 3DvUE/&_IPHq>B# .UiMFV":ŘŤtOͧ#IA/TZ}uONfa݉OF!OC*DuiuE躒ReUMDR)z#BTaMQ3:-؁| nv$xe:s@٣! bV2"P/$l+PFѭ F)SW 2FxpIt|t0wMg&kQ VK3U7PqȨy WrD +jbE;_``r-JB%(ΣHFD2w`:6$ċyu'뀾Ua ~g@{xqaKcJ9ՀԄidO7 +Zl+" yqX*V39ڔ&]&FtQbdلYxIv^[o k>aSc4!1$Qc5<ߺDLyu$E:`XdV iޕ#OJT Kt$_Ջ4G$X, 5M'hDeߵDmhhrd ͓mdMQ!6wo*XZan |B:ۿZG"DҊ"y@0\(`t4! +骮A'$Bf \Dr|'r' B"Q=ʴXH>ǚṂR)qY5!d~+%yvFP71#q#{$u׻r-jpM7ŃBy1x)6 qdER'Z - V2dR8 }a* $lۑE`W M쀒u~b$غz@)4(V߁[B j2âJ"DاJnlӍwRֶZTOq-#t%u)Ij͗h!)~}CUB/0oYlm6ڐ @Xhvk_'<!It/̿?Ksgf%'`N\pBH5-8Lz)I $:Pжx)%cW y%/ ڪdlp 1و4kp1;4 PQDJСF2pQwg'(-A[5?d&o]v[>g;oEd0 +ʇ8<b5y +>pEw1*(;bJ2! 3Q~@ Rdn⠨n=)WKҧYJj2B(?$v:qP̣[:Nqx0&uגctVAH]l?'S‰>{Ȟa Ur0%4"&ӂ2NK4x驧>=t1B@&`?R}tJP(zO"&<@JFad`L>} $H)+.oKD@wGw ŒHI2"]oM?zA`\@ԧూ_@=-6¶t]0ɪ>p0Kǯ+y0:N/߀/:j;*'9"rJ;Ӟe>Fו ͺ +h-DI& Ab! 1޳OI CI +e&e ^9t1S9 $#D\ vpc$`DH/ y# ,Qq_E>ǒ4qUF#<8DF5wU tx]99k!A\DYq3ã2ʛ)A8?:9!\F8}Y͟E'ef57hR1i<+PDmTble]&1DXP}><M'!ݫQ H#HP.%`U!L Y^C2^IaaNori@+gd/gI< +sw +sQ:{(|l y2Ҫ^|9O)*vumDD ۜg`ռ'I mjrP` 2u f0rQ!ZQ8$c/;\FH~|I+'C::6J]S&F;wMCHM#BZs 4a21Hb#gR< 3.'wFfk1ICg CKN 0QM9\c+ohZ~%mKQ}~iuн[2*)ewqu+GPiü{$0a+;zQ)ݫ&<2bp+ܢ B_0(EUZ@wyc'ͨޕ+{yӮ b!Ə2t.]@M ̅9i <#9&"|*55l% /"5V: iq^B М@$LLofKϨR=F4A|qNAJU2dDG' CmM/T-I1: +Q#$B>@86^G4ug%/٫_B࿠[Thk%) +D5N N;`60ySCFGy`X.=Q<~!i] +j1?0-$TL[9iRRjF2R=Axä [s"4H<׆GJ-vaQK8ﵵ!)N̷R^ZB%l{kTŃ\LJ}[죧ԷlNoI)&? U&ԶuZ2C6rjh#r+eΨÁRƖT./3fk&u r&OnAlqB\E1ԱsI&l#F{vsQA;>J +ɯD [ g4AڿԏMLJ--j-:=Q5$$.e`=avGf׽ԁRh͞~"=rYR*M`;Kb. dEuH^AS+M{3G/}y$68L]ăݓ|M<1S~o0H' Tju@hrmd;) T\giKf<VAf-8fll0! +eA"Y鴚Ŧ׮J/\P5 +E; º>3erAws;N~Spؐx:p:S@srE i:w,cdۼ3G~cLvI)ciQu!i!\K}QEe@o;0'tImrO4eJsz\MsZnb9b*=H@|7{s9Y;k Kr``qaFWUjr8 mT~j*6*w5}WfD5Z hd~洊!1N]yQ}t$+oNXZh #<JOl#8'D +Bv6s%iuJIJC#4 (Y"}<8!aםeϳ񄃤b[LtCc*>"Ȍ嫿eΕrB x]Ǻc((Uai ~Pt+s5ȡ1o!b.vܼDqEki&N5x~QûS{+LlŽpZ!V`5#L>OTbwhCNO͋nkxTb4}CLE]%լLj$ H'JR=OI yPj`Xh8ls*Ӈڢ9dmB$IWcS 9Adi pc5u@K@2"j;\}"ցP4QGuE +v +'8҂`ıN{;jRwTWS4@ F(JF$cH3y+TˌÏ#Yfl㈸¤-Cu,ٯ hVU!C)h>:jwXSl>j0E:K2ߴ4 +^QĎ[ol6{s+.Yfq@8QɣMpt]R #@;Ο)ovE.Z,]G42~ R1i7 }F$1C4Cț:)!feMח AykjmOH fSA5ٔ$vkO ;:|tp#vSĸAMMqSJb@ 0֤9&ܜtX*,Ki  +hqܡ0C@>|׳-554jv:YĢXtĀIl;1;8 j7iLIxk,rmD +d(}ß(T7w^bBāKdWMJϋ£+f g=ʳ^s9Ivۨ_0!0B8@_36(_:^ݡ UϘ&YQ}?_4v:h`+I@G7!M@'0#k#"lޯ +WlwaREW)9t)V:TPN|=yá$B'#AYh pgW2.Ho:蝲j vEupypb޴'wr1[vǫYmd?x²| ܒXDk5*E%N*j{S&"4"B%&3 əy4%[MӒKF= #}kI:؟z@h;\/# +dtOߣf*,+")$kJ-yʍrhіSTBM˧T!( + sVqY3y=GsbUE zP \C¿l4жQ 8o9B_5zB Oi\d*X|[3= :<#xDK]%_W^[gM;Ly|?pb[|&3<-Bם~@kMDZmk4X.;#O![բ*Ct,i`nQ \FO[u8[9 >i,!h~R:L= x`) VC~&%J9 ]@9f>*u5q>L9'mmum.r->E;]Ҧq/IP$o ")z }X?CPLʛx!.t8KxŃiKY_oBՄ<8^"EzLGm5E+Z*bn&3Z_ d׃ԙ!OTW Pf%B>Ǯ7ӂYz(h>s:$Y7,X@jxC~~n yUO o?o?_~w_s_~_|o>Ͽ|;?vgsoy_}o?˯|_޻_W?Og[Go~آn?7ճW~w}uX.?K_7ߌo?߿~[͓":teЧZӠv_jN4W;?o?You}|N +ҞO˻ {Q?njϙ_}w7o䎀7kG6¶=bFI ڞQ$̏2$w,2H^s2rgwiLM]$gj-Z߸ (zOx'8⠯WTIZ2m\dn{u>66'6yÇD) dZDD[{ǜBnz{^A`8VA4yVZSN܅kr\")kN/W)5}̿v=:$F~6–/hz=qAm/JX$ NaSwt{XJ;WFXA DI"*IG뮏rYXc2: JZ@E<Ԓ0C,˘`OULPiQkPMڌ(4 yѲRT8:GGÂY~'3TH @c:*cD{-$B&JDiUFİ>;D^DE.XLe hl E'jAX_{?Wf6u_]37UXg "73+bұCR-R|j($~3}+EYۅIVbs8g(3$2Ko%ղdduH6[1zmAȶ*Z=g@\Nd!*Ϛ؆$|jÒTp +V']ѽapA}V*VxUa<+gY<+%!uO(Ѧ?؍6ۻ |7.W׿Xʟ}`U|GT<م @u|Hi˧D(C Ȩ`c>]H\|LxBQ 玮ON T+H*eu;olܱ 04 6r&9v2U# TMJ6L: g)擠+H*~CMT{=YwL5&T%f){KjIAZ钃`ъޣd,*CrD;O'AjQ*BvUm<0bҵ,g}8 p$m5nHRӭ=PH?fo3>NjZOغwfTtb"~ARA,恵NOm2'[⹶qBS|@,q3F=s -&vݓ4OM)oZ&GMmy|GSR6AM,'.!9-@qk $ #!(]( HRJ'.^k\ G2dZ8Ibl=@af-bk5ЁS+(Ё; +,1Mzl$?g)"v +,# +L8rZ%q5WքR 9\*m) D.Fo b{|aHQ,mڌs_hֶI +C +FM&xyԀNnj- H +^40բ!@iDXgx6v4>4,%p+nyNh~f\h+pMQᰫ BFn4Tdy}]c]>^r.A"Wh"չSvS&bE^Nvc.aD):s9Gm;oN Y◙-x.^ZRQ*Ag +vJf^r-U[OUS784p=ȦQGä]IʤxٛRN)jyR,k0˶8$ڲ5Y'B0y%HB1-t!E M`>Yu?%B! +Jp*<*6*j=}kk+BؓQ r_""oTk9ݖ4]&`v'v2w3IXL_40LGwOf5h1u'QCk€{(=d(ʰ]MKi2S`B~ږ؋< 6lrvtMq"'@2* EPpہgs;8Td̑-vGvPdn0.C:e?\鷳Jy]Շ{CÁȮp"v~W];?E!F_ b1Hٗ1$RjfT5|^^_,Nu"& +xpOZmf 6@ލXtHg^QM"@ŜԼ% hh&G$e&1񼏟gMQ RYKloӸJ7W>g x bD W#-]I@Cf$T1#>Xo8fϩ/uW#}N}M-rffw;򒜡T +/5;TWJתIDt% >}I)l1 [ŕndq* *ʍrA,m<_d8)H+L͞sm/vS)USL=[NOZ >:e57w\\Wm?dIOqf>b[U +_v$7ĢA@vTHdQtb[xI ++E9%w`$t}]?#㫨OCq!{:Y^ kvaZKiS7R#cv()) mvug>[s@8Bƪ)db`ש&JeћK+G_|H +jHۛ>h|߼X?@w-c:Z^5QQ$4)_ Їr#%2XgXvÃGtzA Īn3#-88rҐң!хb]]1+E*xY{rIQ*X!$^1_,:Lmנt@LE/Q<m 6toS܌x\L' WKM=dB*qy1~Wm!(Ȉ s}wW+K@&#>亟QjUg$ZvP,XShXmb/`]PBA֭Q.| 3 ւ9 jhԭN +/Y6 \J@\]#ŏb1/2]wu(;Ed o<8Zw ~VQ'܉~vU!?ʋ=7Iܖf.^lΏ8RUc2"#+_k@ ^(>>3J2+}s {oh $} 0ނ58,߶"HIZ +vSDkDr/~tID@@>T12 E!b?c5(c]Fu 9܄ +Z|Z' L>\(Kq_ +Q9\g̓h\conp-jg@*?ЄzfB0qMf<(;fKSSkhCއ@MI̚ "|C9j63k~{蚑Ol'Qњa<[k>H}4䏠TAoͭPfM?1S[8Y=** $g/mP閴L&?j:Gj{1kok𝏤[ary;fs:㭢zz춤J*B;1=IjU):3s/s_>zh.u)5jT#~ qil {9O>$9y|3$HÐ{uJ2ki?Rr]s&w;ҜuNy6`=y.{)F!R}Qj-UD=h_kx{kXK\KB\+oSkos 7)j7|~濊c0r|w?UƐ=ў:jt!7̹3ѪI gI. t ^הgIuͩ$F8).#4>%}ܝ@1[tV:Οc0 Ȫ!"Kv2NHF#|Ͻg{w80T8Ϋqn?sTI ouPDmTVh)aT`/m_W(;\w32JL;+GlD]˙aݣ=P9P0T]VCaŒBţF#(tJej0v=]6Tś.-T3cS +ѵd>Nԉ>7x +$=Pt2NqU{ QYu".h焭yP+Yoap?A^*A7AD*A1ަd+'wUUEij)G|PlW\m."Y ,fS(JvlJ)mH]u*ZV\DEyP QּT8T>hP9/Sԧ'^A:д~]0THU~;O\Gq|qx2Pŵ0o.ɳ}?ULzMHJhaJૣHm>QF@"cꯓgGyu1 nћwYK[-#xe +fO;f~^_Hb9%}K +Wp=.ۛیlewo'73,7u6:#6{mu}Twd]{4A_޺^wl15*]mKqL]} -tc?o|4-!VRdRtM#'*)Яwj͖\H;+hkn;V6Jr`Gv{;cBsc* XC+S3Gd)?چYtTs8PT +#s5<6Ps }H~3 d5P<'QBk endstream endobj 47 0 obj <>stream +F\9v-dJ9m5pE + ^A{LgeӾECflv,dj{BWS!(Wf.B Ƌ>$f-({[y4/>WI9{" b4itJNbA$Qmj'X˥"`~_$fzQGrjwzC`,GWg?q`2~rvcDtO'}BH^p~R'-T%QZ@6OYO#cIB';`{0Ÿ|8, fmY*/> `r0Cn +nz{3'2>ܩ#vLOuTGVrqXc5ϓF@ͮE(V/m8/- ]bne3rhD{U-;x:ЙӠoSp¯ۦ%~MDS##wyPx٧tu?j)wZ+m6o؛fn{,k`7fm_KL=ؘSbq]w <]&Ȁ)[]VQeo ~&*`\&bAb +P> Hͻ n2p/`BI }d踑`[u"/Rkg{Zb)ȱ.D@|dӱ{H\M"]!Wbu^dݍb&Zo]e8-!]URם$¾]ByHWvم۔QN!aض/HEy#"u Ya@jg@9lѐiC@ "An'V{T2 /t)ChByٌ~Wcw*bIhh"[ȜLWl$%HjY|$Aea^lͱ Χ?55 jV.9t6"H^K@dZmtq/ +L*چn©-mj ZmRw6&ﱻ4Ѭ6{hz+]j7.MBUFI ax]7U۰>>wm$O=FEyE-R+] $]*'i^;ٶ(jjɶEAAƨe^d@[KɶIqL FUM͹4]ob a]_wmHk<]#ӑm|Vx2vakc+&U\6&JjT"轈}VRAFݖ +H@XhQ8n42%;hcfmmm<d6hoW6Gnj0y`nk34֡d#MLgWHm+.ɼۻ$uV_J2ڨYv̜^QF4c|?rhoy4wʥ4gQk/a=eZ*زfy zs;Kftb6f9A_ +3` +*7p_Q;dOf4,Tϰf +^q ދIgaXP"ㅦ_/N }a$z 3eK:l+||iɅW>e"oΌNs_y%EO`*Q55IàZzFsS h +՘2*ŘwDB[6Gy/&&n P&L7k'Fpm6fl8$sƏ\8EcujmT8nŚ9pSc¦i: 5s\& 6vì &} gm6ƛrGL[+'pv zE6dqxXe#hJH UDin>'ٲ>h|sH[}tyl&l6^LGHP?D`gqI݌:]\ˀF;+ݙrs +$5f4e y (A# Jk?>d(4&dx8Kz|H"`9nTC]a;,ŰHf;XrEB$Љo"4cSH +T~Z +^M謦ƵTs?kf :hPh-=ӹ4/*,ƅICDQ9(p " U 8tCa>/hʳ4";?.QA&_B#4Wj(s9d S?6Ĥ%0/+4C'1KӯK<,[(XAf"N +ySF#xξҜ3Db)dj_=pFI{L5\t dRQ7,OjCv+=ӜUi^W63h3@ #Ņ{]Oݿ?]Lx4]3lzqK}ucb_ [~r}8>R|DtkTV#8xU^L X Kܢ6> B+zW.J(hȗ oD3T%`JNNfR̳/}λ!z۸G6GKLCsn %j [yG,53scrzo ASUv+$;]dp+ŗE5wY&nT(XVcgr[ӎ|5lJ`mW.{_Xfr%lp]j̅[¿1o6kLï}iY*5mqdߢ[-|#hxG|{?c##x)x^C]Ti6ANxa Ma&&Vه+qq<ĚYE*ʭŖ43f"gۘY; =f"iO#[j*)nnQfFmSVrp8˚SxْN̞6\e֌6͖ܤmTTR:[zuӶ>Nh>PށhK"Vhcoْ۝l#nh5o5WZG^ XKlpu-l}-.,/޵tje9nkսZ+_ۊfjnw&wm{+m~﷊89vlyUu[뎺U[4[1wZ𭳰7WcBOd]ߺ4[{󈶢pB۵7mO/oܪ"m\HawtW#o ;{C`l]q*Б=`=HncypVd`mA +>#}.0=^AJqϳ£~YݦV֕TXq]{>b5VTٞYi{eŵ='hnKl=54v Vhߖwy `6d̿mxƻ݆ ?Wmfqnny 47]Bt,Dn @[^wn) ~{Pл[>z {?_7Tm~EoYXv]}(To]zXc6!J-%GxiF"EUK`v1H o:4΋b%*$GΙמeEݔçZRR7 +H<%'+fQy*E¢dWkZۯۼ9m-Nh{^ܿZܿ]t*kumVM:gx_$kTaAq.".f/9Fmfn$&Q[rȽTa2yryoKe3\[qVV/ ܩ/d;RW%wɏ3V# I[EnhOP[}h_qsmč$>jIQ#<~w2G@vr{ +[xD *ldBmgprc;EoďF 5x&Mݫ׍GY^+cV+$=@]"Bہg?+$smW +ڭЍɛZuw^ЏjP^k|"mT0?a#riUqAƠK $u +FWT',&UNHBRjIN[gVU]`֊(K\ZZ<vz.,w^cANIzU<'7|`|)g)kv#/>wbMO[M7D(eH'و5Uj*<~W)C[J̩¬NZf{UfͫͶ(5r55uոJNg3Sh+SCh3KOCQ~29,oO;,z}nMhۖv;;qcι;Nms l m{9,ۺY/o][Џ:CZo=h nnW[ [1N|b6 ӾlM?1tF!(v́"JٹȜ6Qnu*Y-87F3͑" â=Qy؝'Bpq.ڞHiԚINX9n# ' I9J?IϛFq0sRP-7_LsxY3MCJ:|`MN^àJIXk? 27?Z( 4/b;.BTX uOU[aܫ>þ6t%f]kf[֗Yo3 i7͎ͶβFouosEu]TܾI4K9ɽ ఝuOw=:a͏ å̞}pNizv=#b^%hUiTOSVмkӟ w<*GSD`l]ވp ߉6=2T`17{ lu$^m^7+m{(eFQMbtTLEM0=œd6e]hEČT+@d; ~3ivF|?/DA;hK᧟$߳,H_O/77kl(挓yfJ"C3KD<<~JFlE4s`Ntt RY!>5vGJk"Qbȍ9ɺ@t6'6hiѕi+cmM0fXO4gdamuw'@u⤝(hrĶ4EAe3ګFmc6e מk}54[Bom`DTB +k3EJ.p_5f6dL#oi*N::Z6SGҔM A lqNJ;34UB3USnNQݭ(oT6PqfM2:Nm̋ˆ= \'X&-)@}릻bSJ414Jl]G}Ilu"| +aeO@lj^ b~yU!R*@8?;8}k$qLրbC><~Qhc=0UYn:wbW0" lffڪ0u0)AuIWn&J$(%(1-;P(ʥnw ,ո; 3iTA񒞲Z#jloW``e T- n9D8$CmǙɹ}Ng} z7ҀiUM_K~3%tGb6 rM0Y;:9lqB;>{oRbڋs|;bN#LK0١ Mt;:ypaR\M˟I,? -:#P\rH5 Ş}Q8d>R.eOb +:)Eޱڅ HH7o2-{39N ~]K :.!>nKF>K[M2icoF$X9'Ł#4kt@h.ACQ@`5fOt=ϦsܓL*;63F䉩dü@H4^&b +5 U؏6Yύ m&?yLLT"v2db':QnS͜(+Fq.*)LT7uP@6,%$>9ɛODzUx=Q[[qQ-Avd8'(Ժ(IUqR,iκ:޺%6)Iq?qMq]<鰙'^oV#"v~n)sgx\0ǐ7 QtȚOt@޺Q28`p +LJL;'v~j'R[C:XHn'ixLpJbu9#■~<*8"@u6l׍s)0jTy`bA nPgp 3r%1_uP('Շi +D:ن{X%LQF`<)r/[ +~obqh\C/By]Ht83[r#r%n#y4F+>X>C$ךZkGӕX依JŜB=GTIkk9±(HDs3nO .G#M<E̋A;=40LqOF)3-GIj7β4gط6'ӵ~K|ԻC +6@B^3 K8. A.N^} ]*ÿAJYj  +k6ӸfouձqBh&nlmRk" 8FU遞օnY+䯻E0%%sě]y*|ueUSgz/qaÖmF^kČ֋囙 )7@XR"$دx;026K76fRk#YEJ1\Z;su4f_&//"@QBA'Py889 xS I$Sao2o%PqcW=lbMʏl0IϏ-x3s.utήҚnV?@~yO!l.S"ZO 3=&㰬HVq"6wpdbB*S*4ۓ YQRn~[/; fQ6X`'E=~dΜ/S7b*Q婷Eh4M)q(5vi$ VSIJ[ jɅK55{zqY2pE68\Rdj#7Ac'8/, deޛsO|ϓW3JJbzk[[Ma4 @p[G܋BӁX|`i%Iz:5@t& '/.& kXy>Tt=i9(|2œDhQ6c >hRhBU*(> +/I}/zdѴL.T*"r&G?H6G28=5 >!)i.pձ6\Φ|.Z+:xur j&@R0Q\ܕ'MHk6ӗrŅ3(v]]$|AbSvi_#vD0s[e3_3ؗ|M |qzBJݙxuQ }11׺_w))i 8uv6Jg" uv39U{ FG Z- +EL2%_AZ/.AYj[; ŇFȝ?|@!"9AR-2RgrN 3Nٲk\3&{f`7tfzoIfpk^W|lyInbWJ;p5 \ЏOմkD *&. Y9 s(5ybjk3b:Ɣ{Kc'vSOYS~y,`OWF0O,>؞QߨppGlOhp7U|q G`p'Nr0s4NXZup:!i%bO3פ˷d &]+ xX ȼ_sjf~}FċK52$6l8\n{&!̯H?j)BDep05XȔy +KoPtg=;=մ)S7WMR3M7h'BsX14@`6߳fXLɚ]51uMϚ_wxMc+pmR6m,f{)uT6XuyOKycN;DգN2tʃyD!& .~gfMJ֣Wϋ L\OoA_T-jrt>' +7q凩$6Y*OqxL u.W#yMv'+$zfQDžrzTwW[*D k]bXᴳRw$ϓ鈟xr~ՠHedLQvMNAUi6ߋ8DZ&*dp9LKNh)I .߈W Y.C|< +( ] +7Y +ae>qWRt|9[Flc ('8"U^R ƨgbnA 7q+(kEIT|LZl&{olb׮'AdXPL\ǀF0k]m̍l576:9]p(4IIM 48B^/w4C4 z:@Gq3jA5<w ˠ6jA.>-DkjLvz?*W%erpNUc +^^AZȗRw8>B/hx=;-gp@o!XNaH ѼQvW)ANbGA$@v*dNmo2PɣiWyhBԎѮ)"W4C%>ّ$vE1~[D*N?PĘShREiv ; 'bqB]pa~(d8;{ME:gA?̾w-Wx8 ƨ F|zL0|"zN}4MŎ +e'`ٵfҴH8ggPpD.B"F"5Y2H=P:^ &\^JzRd9ȉ Hң?%|.W{jj v(FUEQjZf!DwP71x 5 ЛHp~BaGԫ[+utǟpԝW_?P y-ohi}@3]Z~cPv mh$u)TvOUN)/6'kg.)(9t|`vGq QCwN2+wz3X_U\%vWs넦;^cezZ7z`9on=Rurؖ d; vH2>mKGyu2Q\|H:𬠙|9ciL/HoҊ1( CFY1ALpu*U~N~4`P#]uÝf;R *Q봧`gU`S` Ԅ$7 H`| j(ԏ TE2Ou}mPG_\ ++E|{ };QE h5Ҕ[nfX4z(cLY +flae&A=%&wA~?.PoI~5Ju=b}QXQ`R@0Yg8^+İVA-.ꫯVf䪖SI eE zǹQV-%B) l-sZK$(7mmDvh-o^Ғ̼zuLQ #oԖ 6kJrg}Cm#;6‚5f"*[jܨi IyS[.1/Ֆ +p^ >R +}4ІUW63;[]m#kGz`) v_-A'z[Z{QW8FnUb2~Ah˴E>8OADg(oRPKMEʍ? `> a:ګZOT2$aA<#FYE%]Ź)fq +j(yv|q{=A~+t|;{BODhU49)-&g?|\є`_Ǡ#F+w.p 2HQđ'(Q|"r&J'ܘ':ʃH3_(sV {\B(N1Qӫ-n]ۺe~8Q.h3JĖjK*(3/i\{&8ꡪCцr1Y@vj5Tmk\L5d ظQAA7YԀ+z`G褂U!]hJ[6H$/d4/H( yG]9|E(s)^j,.Bg;6R,=3\Y/ +a9%:H!DBڈ%=\jOL*D˲wr4ܻh+V;8;}"M?: +$Y 'c8TNdcHLYq&q0&ы"Hf7P!0"j|J,*̅}ч%2^ cKFNl+Q9#Ph 0ʐᇲ=*Mw d6UxYm"(UIeJ ,!"bńNc$j*2H]FmۑTVftↈs^C#Q\^@:_{ΔZzfk<ڣ`Og*G8/IgJu3_Xۺp3֩w3 $,.8JW>,K(#3ɿEN G@ `njIv~DR݁X): Ns @.X'!C@,#gNlԚ +&oHm{d 't헨N~lvFzȲ_y,*MB㽁;\LR5H1Viԝ7+|%ymzދK-v/xun:.89!Gg8a QvPd'Pn+a +jؙ9f44qHе\k8DܑvD!~*_97PaTvFqY1xGz h㮰1fFxݮ 9t_&ߤ' BŻlVOzvz4FVO +IuwyTPy&R#`k{(8 GXq|.9-?/81i|b2!N? {?@,H!An5 < Dk>io k-)G\x_Po0Vn[-ypgfUx6 ;m<0ЂQO:lD!6Cp[a\N ѣ"XLD +!}ԬCp޵v W6Ct):/m/OAC@^X&1'RE\ ;+)m޿p8mDJ( T)ә&| Hoωl1IL6%U& n[j]jsikAG@55(?Ǯ̸M[Cx|ښq{|)&l$kik+ +GM[!m^ɚf`r˙>oJ0Y$Tּ83kU䭙MG|>o͜P!8d;l-K GHXzW,I'\*V$3}˞†%WlM lX!?r^g[/Gfy?Րvw|QvgV @@ BF1{}leaA{MbD +]~1qؽsF)x{hc +@C U1߰@h!+#6``8[7Og n@=ueL"48}I`AMᩊ/1P]K)D$ +FC=b:=uQ"UPGk^ł3!և|} QRUW!ж5d-r!{֊&ΗC6}X|,3(%Lpڗ}>?8N'LPOLy!k ?`*7Bf a'Ƚ=A#'FV'nLpG&]5J=eA{9>ZDx`,2Iة&\7(DVT{1/\=/؂kOH+P2?-P`#6QΒkv}VK/ڪ(]!Jmh;hz=? AhCa6ŏǷr딑f',1ňjDŽ/Ŕ0(4Xc^7℀RlQ 18m%~CR Ymg\#TN{vB#45088;jړD)B'h.Ab`V5RP߽fQte 'f\>$ЁIچp H rsNj\N*LHK'1S%4/*ISZ4[PN%bzM$A;1FP + ؂".D)NZ1{|OS1uEq&|O1w$;Oٱ1g愬,Aa`~&u #\`I +n. 8:7h'鋘ĘiCr; d4*G,QRgPiq]cԡvќ"AΌ#酎[m{O rWNw&d^ӺpZ< ]ܠsRaeLc7V_'uV%R#J__W3;!@'8J +oE(Ύ'"{FFpOKW9f?v?qDDHvYfnH)%@* &aaDH-5)̵`ߕN"2(/g$9A `4@vܚelS 1>DW0{]xJX#L$5~n4'̨ń]iH! .KDccfM!lT)t> XF %QS;ɊXmx`$CGl9h(Ei0?+pq&foE[EbOE}Bht%&/`?P_δ[u<,_@\'@jD&I^36=Q;/ w^O\%(jb =!P|H}"6ZM0:bS 3"#b6M;M(r"(%UzaLyizK !)%%J 7K5$$ 'ۺjg/k2w^^pkR7 *1)<hP5+!C&$BoERZމ +M89c۹hM`#[zk5} ^g+}$g }O$۔*CPDؼ<"XVGG1AhK-m78b`CG4 +E;;O!dm/DBEE +ۦ~ncUBʜHv2x -Y% {9!FS4L^Hz2rf6nϮ%(F䆼'p(m앆 `tEWcgBkPө/̜lT 6zz5`ˑsz8B +|"uny,ŃvpkcsqQ#5Ĵ{}=Mi( ; ^`tEԾ%&MKVhgku񞋎H]&~ I~Ί=;:U0z3 R+8H\PJ$P8/H0~gj[,n]sظ)ˡPնF:^p`RDIS*EH}{L^a#@]ig6um@Dwah? K`w;DP ` H4;Ņ* +O6f6%ۯ}a@&*PB_m#fߊDUNxFa7;HO<7ci&D2gq/|J6F8IB(B}\eBn.b:v*y54R}>Č"|ZE &i(j(㻏cBlWNG'"ͻ~:H:&Q WM?P$_*k֜+x1jR]7G +G/ 7Yrbp X$B(R_;҇mOQ,|}ILCV(`xԌ1J(7lS?eZa&zn'A þDLk3wҮϒK"L7FuT ]xL:Lnu+ +4wQ{bC]mlQQOF(62F(S兀,-zk1xD j~#ZQ[_;& o1G݄dT>  +;:OUwO *Ř0gHjA᪞UFeLqLD/KllJ]7VHfbS6^.Z/ {6KiqnĮ-UI#S2Jne؜4WSa k2:Gg܆v-y(vxӗ.I 뚶l&za~㩂.xݳ\{VEz#QvOP8{G< -'S~Z4F i݌>5Dí]GXG:S !4Y!^ZK{rJRS^3YqIPUTp&ŒVвT4^+2S2:7&6Fz<̏-=k!mՖJ]9}銷z9 +ɜ ^Y $Y1nk gaN%z{r-ntv]3o]3q.ٮ2tgS6"Uz5[:Wȕ:w2ݹa 㭣W@y<8᳜|5w.ط! WF|Az]PU؎ka!O*kg+ ~53@ox!kDעH...Ze\lԵܼu]?3;{v`iƑ}]OZcʅ]6<\twߑ4 f=|.{,o[39qpʃ@aKfI v)akY6ʼnpXk T\ Q$|-@uuĆk'X{rlObcϮYi~3"2'/)R &* ,¨$K +ّiPYߖ(.߲&Hњ]5}.W_ۖ5v'ck- sAg|WZZV~/se_+ďN"~Ngk-$XcxiM;ayMQ7{}NUBs`Otiw;szMn&]ĺubTXT*{3 U=TjL f:\p_ y!X~ti/F@ Abv[3o%UG3]Ax>@hJ_"CiDsR ~"Z~?Itu݃Fx;S!6p6{sDPp>MqfneE|0|LFʙzV Uz2gTSP>P&&-{"[8IK(SZF[)y)te.=zBvILD6 2^z=TA* pgj;Kތ`TfgJNpuh WI E,*OtKМh+a=ء'HAJIze-Cy]!nbV8եS@G淲RXQuUcWI$a Sư&vȚXCIK~,uޥQSQzg7(hGl}x ŗNPzܿ xLpT=aw)(k@[\R{Gn[+Q9o| Jغ 7v IO?8lgX(HaN;v1b^ S;D<6aCzh?m];Vv†=p>XiD-ÞD?^^qG͋~,Ms삒.wwvW~{07bH?ZiiG"~^gOg;Op-?fvusCOG tAX:/?ugIi>Cj>Cj>C|iC!ު9GDԜ#"jMDi9GDrة;OI<ԝc"i;Dj;Dh;ODvxvxex<5<5Λ+uw7;nqKvKs[$;^:]Sn#Ez3UF^9 ]מ P>@RMxHdMN7!;ӊQSg N(OQ<ճ@A+)(ED820GdQf?Q(EN" ؠHm}zTԀB(m >ȇ(8SEqIc&y|5}";ϩ-~0BT*lM@@ C?TV뜢5ZSmlHSAc:w؇7uRrx,Y io*) oFU]ZNz4tVKBֻ01)yMRY3Y$ޓŠEh]=b;Ibv0m:{_6RF{tg09y|q. ނHM7*i߽o,d2LN]?׾LA{`ڝ{:;{} D gɵa1Y)eg^?yw?ٟ|wgG젽AM"?E2| Y13Od=05a6IҁWVx㉬)y0мR $Ǐ6>\8&LuQt~Lfu&Iirǎ,(ڣ[:A1ruC<*?y6 ifJ@ + r:;ʂRYH)&QC$C^XSO x5ڄ%0"V]=v KM<Ff +6A~({\ S&L!͖0z6QblVg2) ޖ >՜r=FtRCH3'n p񠢭7eoyvdY);;~` N=RAQmYZwDGL&6B0r.%n6؆: +_5S;[4T u )ȯpOZKTKG?ݢЀa܅R@4*Iݑ!#fϣ$|pLSxp=lHHXYCaAnl`\cDf)svhq3"X[rA@+r$rUb bKx4|"Ư UdDA?+tVY{Rc/)苪&38, #kgeFvN ro1}=a (DH58~=T;*4 gg6Ehѐ%j6%DmL=YY`65h|`if;,]Sj"vTRT+b 82S0N#5F!5~ϝM4{&T5 +.9!$Fh-DBdVP)(~F$dn."B`$gPDGG?NZwHa )T#q>{j5BmlP<ӜIv̦E鬒5zuFHMfTtXfwr6wl4׮$-Qudv-J+ۂOsF,6LntЉ*3u 7;4|EC=Mxq!h'd*/WNjˮk>DVr;T6uTVhLܯIE!1L7uP M +YYhx[@͢|+N;:G`5Bण;oo|9 ye28v̮;І?>aPHR_ѰkuDq!B +Tx|.3MVBEbЄ$Ri9֡ *̇Քm^"!T~TP4P\dCV*9 +I1pDž#>(9CTa{qa%^eT#{ܹZCDn5)Kޗ,ҼJܻX ,/+x1Xufڻ̈ Yŷ 5Kpvef%4Q$49Ǟ."={௕ ;1y ?8gǍ.>A)3Hy +w0rdTy4JmqHqs3V4Wę{R Ծfu?,9imW3a yef%¯K 6HU}LFM"^F]GQ3JBeʉSK&:r2["f\o¡b.eqJ'ֽ}\᫦Rp%%dލVyK^+d1`?ޜ \z 'kdx2dp3)9AUX3JH-0[Ͻv )6+ef DAnSa8@]ZMt~1A#%&p{ZB\j[cDkrPEM%kP`6ٹ|W"%tԼNBRvipByC3r@q@$v4l އ\I[O–wظ!C!MndUw ?f<ЙNzLbyO22*̦]` 1zZ۱mb4%1Dl5qnB1k|,rE2j%Ti,XI$s +BBM5"硼UՎ٩ +%h g{>F vgYB̠sd,W>OnCQA >AK4Dа }-? ̞悄o>4eŲC'(h$P`ڵȈ{u@aF"_E?nkZAsHl6O? @j 8 d֬OT0Fpc%{߳fX9ڬY5kviwܞ%)x5?Rkjҟ5O&ߒ5/ۚvM?bcMA{drk͇ϥԝtu)~']uJX B5 N&sNr C]UVϢ]=ɪ]Y\p-p%2Nq6W,"W㌩fhk#gȹ%g2(gJ~ܕSyt-roGSy7iR!Ԝjݼ+]y&Vϻ2[Oh9RkIwĽS8zp'jsO*ţ{{-BY Y +bwQ \Xk|VU\ ͬUϨy^CZڇF60j]\k-v1_5sskDտoのޅ] @0✮}`mT0lpa\=. Cv3?j="ݮ]63oE]6w\w-H[й5QɲMS)r̉L0m'iWM% +bH9k҆z%tVU0V`T1nsE;8L,Ol{U6kxpMPٯ5-Y%2CÔan)Z—ϼE.I``+K__S׬=5, _5_맵^~̫]֝5Ks:ͼނ55њ~yDQy/2 PH ˑ:TGd7*cotDŎNn{$%I +HN0 H9:9O̪w޳:h1\~B.,i= T@~2~_V:.չZ}>kA!OzT@`j_HAbݑAgB .Q*ܹwo C;_ +>wk:TO{jzU_IBO739C${&ŏI`ĝ*2P Sһ\~'Y7,w$۞,Btjx=SoV9]H7v6AoQz^nLFخOt?~N?;]-IYNu C2lRpP['ԪZt#Y(,RCa<B,LJ$Hi髇7=:,o%ɆhNTܑ3˜G*%V1?HCYT:xD#&@fS?y5hvJ# $pht8i $U2ao3)60iUzJ/V`v@V ico;\((t'H$i\|P|)ۂ!l`G@U!Rm~)1[DC$CJ5G/g,%"9 GdS jAvNoN)=ǯ>P{U|Du̵S~7˯?T0ƥCoʜEP K_Lt|8U(NF1}B* )dTz ; F`t\bPbRFR]Hj].Nhھ"3 " nҥy\E*_}AÐ!E=4c`΋ +L:%pZki-|||u]TN:]R)b¥ٙӗJy 88dPQ;.J03Y7j)=( †x"xԴWA=$dT!i*1BR8+BQM"&C@ +!]`[ddp]ՓR +n5Ѓ$D SX]BpR3{:޵*n5 f&Q%系Q̉ie#E7CsI(; K4ZŠGpN7Lɧ(/) LUhaª gpb)Y T+SN"hw +</1d:2f8Qռ>'`V~;1*ΣL$?اהE /4R!6lqIY#35>~/+[u3A0xe/{> Q)'& Dz&h\/⾰N vJk)}0J.a9ׇIhC +y0=ʊ,ʚ93gF# + +H5VǛF@l`ʈ-~b|~#Q9ـl:sgP7ǭ(=:$jSӣ?K )IeRz-{?!\qkv/xs[; ^nlzwuވ҅?ww/h'`Kz}t/ã_?7~_W?W(G7.6?7 +xo414xUK~>^i46BP7OCzXfƗS7Yz k0gz61M'_Cpl; k,~% 84>>1#GLKc"xwW<9?48?$?T'd4<c|Hb|X/19qXs_?LI&gze|/hC|{tE0:%b8+sNa:4ߎ8'kw gKiTalc>{zs/BRsOTëu,nZDlsq"Y/_yn;TN?Kz1b{9M8o c ])c +U}>z]c> +y{2]UYs5cK1c:3[(-LqaEofrߎOSƍ9nV'1Eb@->a7_LRZ^3sd-8Xjs C4%jS2Le^$s?2z{4UV/yȼv@s2B0@u^PFiq"04㨤Bs$BoyW+cc\tsDeT㷦klC!^$k*D +eU'ks۰5״qOCU7| )_s|ۈ>JwFqT1>cLS՞V#xwhm ʼW݇T39OײϵQ{/ЅHӲ?:VUu;هԫlvn:ayDKv|cB#a|>}[-m?es 3U^se qe*, inK| y?E\KvSEV7jnKS6Q#Vf +i<4L{"@8$H͹11k(V"E|@u^(a=oI{9';M>u?x]%;nq8zv0{k=Zn|׽_OxnkdNzW2zB08ca(R?+[y,Ydz"KfcA >':;*3ӌpR'DzǂBŚE85u3hRxY@Ѿl1j$Ii6;}Ω:%Rʬ,uթhqZLwٙ],. \v ɇK3fߣ{dxT%i|1; W$&yLLbLX)4|_=:#` ӟfپ +:}[DG)CP)ko!n4rEg3ֆexk/$;ό40__7vNGL0 IL5& Yr4GDdwuPŌ3f{0pYo -NʬzRj)H0tZ$ 0/91O=P4߇פрqu`a"*A<%2$84Xk|qv~xsG#[1i-c#3MNmLk\L$4xhF c@dpғK~q \!C$Lb6<*WW]jlŤ1Zp+=F6nZ nQg9%{QhDLO)1EcSr*|62z4}mSS_N]GFe[R-ES8MQZT@S3 G4?fxQ;0D~ǵOI%nar(*" +?ŦKӵ;R '߱bT?9 +.zj}mwLDª)AhM@Qҳ\ fP3&`C3cGiB:NQi~=/$f4/K&8 X'A@ +^K@cvSz$4E, `=5$`隙GKrJf}z,e\6GI^Q7} 4s:\E&:D;^U(hkk;妾41p~C%f)tqwOL-@adցqtZSMJw}]H0_T,EH8iOR0;EY:-ҀOYUŧ)QX6!\|o饖Z/Ko zitOH[.|F-ci<~դzA2,,%,:SQ@/oVŤm<,0~St((WJZLzM-  ڣpf=qro0VxpM{d`զt4UwlOLiu)Q;Qu03Qi|LDlBN(ն5QG ]BZktiW#QzRը/3S>r +u0J唤^O=J i%6XHCq\4g%l=v XйvW=3sC9XmFJ3[Z8@%pE\K03DN|Ufe!#ztfe:+u3(L~*a V2 <:ldijR߁hQvI$ +f ^|Y`1 %/!|T)IO=R278.!tYH +DF +LTn+;cRR9Lf*e@7MC8BPQ>~ѡj|}n d199RRT&ҐS[x7gk Rkߑ-ZZyaS¤M/4WAҡkQl$zͲoK+Jw[41NqZh~"d);|:1$K;vV¦dFAl|cpm2޷cRDE"9qh']vJAǢT2K ߟ)ih^_(i)+DUH +>AêhȜ\74/_{+MDgu̇)PD I +~DF=+C!yf4~(G^R5{&V)rZF= YDM2J%3t±a&hK5&$lixZ $dm9ԳK8u6ToYa>YW} Q*z2"W!*aIsm{i JלIzc$*Av(v$LH-vh6STD^nf|3Vt(,0FJgu Kl.gt<-I? z_} gN +=N5%4zԩK +4OE:0 +! +~Вe:.$mR˂ E$"#Ąwꐯ~dElc*P(nRLVgmx.;,X}rq{T IӨ%k}:ni(yD4a&%%264u U )^Cl K$I9\*#Nj{OK9 )m=4*=Zh_q&jeTd@utqzBEZcs@%G:̳pVFeWw)SECrRQ*A2Hh &ZS;@pQ_ D (\ CRڽ.i!BlnBepJr%baT"0_`ӕlDiӳ,s1yȢ6/u-Pb0BG +/6xDcf@F6/. KW'+(Ui}2m d9[]]+uPǑ7an(_`/F,&u&4a "2)G!)e(F"눛foF]TDa=uupYX> J)D?B=nIɎ\Qic#]W,w>5KM1*\'2DF`Ⱥm,ڝ(#hJc&䍋k3HeZ*ᦘ֣ &QN/`qE Tc/xGl[f_ f[ķl]GJTlJIṿj8=K@:6[1UܔTm55ڞr^  RXSr;S2z6QouU)QTe&jXXt+M&WTS:$aݥ/O%=TSweZ;TG x U:s?-OI޺]OX4.p]"\iU&kѨWgpBOdF)jZ3T:ih`QES/TܺF`EVe4̝B:עT)O~V?=t*[Ut)Q#%UIn8GN-\xSCnqxDO;nUr@Z AF`)VNEɉblTB4:6YE0pE9%v+ ™d F)_-ezYo&D('l_h"GE ѥ,69V:^=JջHe: B=i3|]cFC|e"K&`|چm@[Jz)ۓ<Xe +LA/'8 ILC o]o8&WUEB0.!;L[g :SNİY|su~!uI㌪p׾8ϡrt*"YXLQ ݺ9% +;34Lݜf+ݤqI)h*x hqS)Ml~E)T^& 3VS2%SIM$KI.B}oH}qq]8&)3=D7ͧLy֩{o2Ifz+k +}DlP3c}C)vO}=y[gR+_xHO-^ +zZ?t}ѫ +\>k'47[/I~B+>u%U:fI*]箳u jv4:$'-eڴ L܃Ro%7_3#n$xt.gi2M%e "T5MO v$|#ܢZjSUxyV'xFM1$E*R}M^uL*c7mO<Ÿ;Zi`:Qe@/Siݏuʒ9ڜ`R]n7 +9dFQL%=DZIr^o\d$5,)c9=ui'^`]I*LͯJbp5"ǘIE=hgiUO;Bsh)׉~FA!kCFU`u2.5٘بW )u$"0ĸ5g2MMKݸUv[6Ja_N7lc+,'moo>dPJ9U`7c8NN$K>kBɦ(M},O*P^zʸozx3s#4&,h!蛚xAV:gș:=ɦJ,8(W]\*7QQ.Lu.M}kBI0qI9:`lQOsӘJ k;ʕzlY94uP=lNx:&*bڔm +DU.zBS+rZoQlY]WuŌ̑V@Y)QR h\B*X"Z:R4OkN$1F!7t:<Êi(Oɾk*U"k k@gr紻O-Bi5\gUt& Ji]cu܍ +ՠYtC^IY[cK,IԵ)+Qh+p5ْ&3IRZ:dJJL[/,$i7T?YѸX{E6?J@[.T>0a:}Q^>p|3HWrV |Bi֍`t6 +wIs9@ # B1&`6 @J2ѥN_xZr͋.%qPxFD*\JyP3dY/:"L3c?n@R!<9e4ZlN̤3Su4oM;\j)vTy\4yT7w&!Kbo84M6x M6D7Ktq^-ô(0|C LuL'& +im/X*c0Efg:9b냃2~N՛RxXHPH!`*/wL3z.)b8sc)p j*9茛8F 5IT +)"zS G?i$ 9Wn$t n.%KmZ3#FWJU*POu8i BJh`Y"Q W;>T+ `:&$$5%T\byzY\$]La: ;0TxٵsHap6H7g-Iu6?u&Kl~"'TVP4]=` * +kD\7ShÀ}Ϥ|0f"!Cw mnhR܍H5-n=ʰw]Qݏ_S=rԆ&i{TD +5Dd$dI1Y]=bJC,X*Ep>%ɼ&j~8 fF¯F`TP5V mj?_]Њbk\0R{7UU}B|VG{3h&iڿԛIQI2UH+j%imɁAO5Ԭt+)D^7sQq֜&M)/Uy$Q9/)d~*0oopJdQ?q'/sURsZJ[voSs|jqu;rA$uHv(ʍ7]9t:4yW1wZ*O+*VRc[wsA ͤ*u*vSe$&]默Isi:!S7>1Eѹ05(I8C YJ Ǝ?G>0ٶi:0UU&#~jkL[&Ӆ_suw N}jb謭ޚuH};kt+7Q~>TBlS+UL:.3;ʞVs71e5ϫn4a%xSKqL +KqWxl=1G َztbm2#M{%h('4dq_dAHԫ:kqGl]rc)=,݅o:I-=k)"Q;>91}V=+56mp>$ +dzBJE%]:Ɓ~zv[7s\sr?Q]BHVh4!6=ӥ>Aͨw)U@RBО +iTE04雞£Rz0J=]UuadcQvaI9J̥v\{P&gκ.C8v{jm84`<1A񈾍WZJ8{q!4_^Z{M6ݫ#סU}=Z32' Fwc"[M}x鸻Z?.?ΨjgHq"Y">MRfC'Lkb0wUsqӯ[_x ٸEEy#eioǠ=In5"NZ'}K\-t]톫/>v2V{#N/_{s7>)z}{ŋWl j}YR H_=փ+|~Yb?Y`xkr}2wKrC|sYzgmYCW-/A=Pa|ԈlԠ +0{6gh@;8XA=X%`Vwa)>@''`U~G}6O +ol^<P`=Jg̱8|2\z `FL3] HoȖ__?|UiD^x|#F_pQ~K4Øɝϕ SܔBϵRCXZMUZ/L[C*ңlIpe2]X #KLz(4?.-B 0,@dޗqt_|_ˈq>ɫOMSpeXč!m9G'ϯK鿪O V XU}~vS#տo^wO/D=~V?{^z2U]A6ꞈ^Ӱ}.ʏ224uع(/DܽKSY}΋UtjNO^;7G% Fm<#` < ys ;~.a@Vf`:~YOX؊&x+XgUк 7O aԔ! i'wڿ^y鶝 jQ :-^?_dD#:)nZWջAN:[ Yne{uoזMA +|eһ{a;?-/ˎ7&/N;8_4dS$v*&j)׏,1# ӢbG|PsQ99CulvP#4uB9c-1+&Iiؼ|ؚzΎ3؋7@ڇ}V +QN@ՂZ՝B%rvX֌oϷl`9^t;@-]U,g@OC6efe z0[d xi^t5|9ɗ@=y[rXD(n|rT= F;9xsXKFSٲ*[j~u"SƌVq箷M56,v^!UͲlǫ7ão %0Bv +`}688>Ux>z__LI4w`2; jD$l +7Oj'yܹVnxOy1.1OYҽ,A^ug/WNy,kr碵x382Qy7[v^ԎK@* !Aإ1S^ G YYh%C6ve׎.G`U9c&'n0¹ 8DV㭜ص7A(^TZeՃqfq8!b)^mA +@pU9Bmg{ ͶwL@=+P ,``3"$1)d` ?o@oe9 bW +G{`) dJީDpTv19Ϡo 2 g rG?ԆOսjcyv{VGA(wO&GUiTg `UsEslu@Ejr./D7˛??"l/~n,_/x9{mԹ)3<)0oN_t{[ ,m,lX%kj B?oɳɗB]Vxu@j'*ʲ3{ƒEƊaGxSdag <9,)`f(/`юUC i3W:5@>;2xXpRWm_D 0yG{--Ɩi)0Fm}P-@<2/Y@@k :*p1u ]1i/+ Ȼl1VKK}PODm/NM7yWAcQz+,w4v.5zZ\:OfA _;aܫ^sj5/h&V7yԻ+5Ny> \Uǯ3~~(@Ұemp+"kDS\eWW~l^Q=oApe N8?`l[5Qg f(N.؍ t4vv Q9ϣmX=%v. +=g@%eYR :VkX~֎d%Qo݇;.`9`cWN#_Ʊ7?߲2d (}yQm_%k`lС 8 l@63&<1!9; |(lXجi.Xի/azCmewm Xk+<10^pTI޽Lg]E:Kq紵x]P.vy +xZTW˨}cM0xWŠVf`яrʍ'= A뼽nx( teܹN7Uf՝O͌Xe,ʂ<gr묵zLE O?^x=7y߲"ް~ڻ `6X:8bs ûp<Ž3XIPAmBޢghX;E`.h.R( (")[ioծSYB.kJ}^u~^WqAX, 0]-z d,;0sx{2`WmSrnۅ<X<nik7uҦ2o n@Ïw;H02ZG`x~(`:ۙvֱ&z:[ia 0&~ 󜏾>pw}&V:7}0$PYwãykPjl|={?:DtS^ȉfo'{Vwc6^`zؚi8-^Dom50@'`IVNpK{|zWj=>z3^c[~i~gѹО?+"4R!!Dht +G[y'} Vu7 oA`R>h< {+Vl9Pm' %hK"Y^#Fٵ%۰i6!A8'@plʳR&֮%9OkAcoIxz+\ޭT^u^?xΣm$p +紷` |(!~p6hz{=Y(ʭ -Āi&}뇕es3[VEt'X U3;'Qv; p$eHc KXL8 ˪paq8\,Y®ɵH& 0iy< +[GH Q[d '_8i(]*yډgwq^4N|L_x}%@2n5DmH֍4ǯgu/` +^~ _ʳYg;9s +ކmLStM*j9XAs Sҏڳ'?Y)YĒa +bd&0@"q<*v7ʀӗ^ܱEsWL_{ b!cgxBpx0:-`W?Zvj~0!PA jgNjȂSSɻ5OekK& X9j8,p`Q7@sXx_Epp@Y +_Ly=-N>-:1ϣv^GJpf ©s /g!,)͎m k8rmNV6UVxֽ-\L|*Z4B`BIk%>܏59+mDž_z BVޣ !*؞bbQy`?Vkw'q0g'lvTp! n[5؛[Âi-dozFEl\?aBWix 2 l0ԁ D`yEmySP< ûr VWIҽ jjT.^y=U`k?x l_=ZxY&~U +=sY3^٫np6,eyL x E>.I^X(jN0ՙLK @VNVEVq扺a z9q(9 9h LwX,Yrn50cWslx0[/m9znn땛R  8'wA4U"+`U;1\TN' {m֕+b-V6ˇ\_:|cC+n=TaF+M҂qD V@?i6/M4#h ^+y;-\Z|AfQoV  N=҆EKST%| v Y=9n0rJm1P;.*ݳUrKm}!ob(jQ 1#8kp_?u⧫W  R$sQ]'Y⣨uvy6{_R',V x9 P`'ˬ<Њ6&k](ESrCq~! +X/@ +Wzb͗U px=n ގGi4^49'XId` &ɬz |^|e%x/OEe/_tpB gďfX!K m!bre'Q4K`rCN:gIy.H;9bDvs0,]`^+L]pàh~GhG-P1*>uR1XL 5@MPm.$Y׏rbTt`6)`,y%`(vv8-mgK6oP[hX!zKo*Ds׶@.˰vX$k4M{W>< emk@?Vse| !S@S:h cQ%d.;) [ +r^yN4bp C+T^7X8[Ew .&䅬&򋠱* 6QQL A]-xтU,NAd ++ɉ6r)` .B\OA&0' )H8N\r)(pڒSyIXa8痗a$lze6ZZF[LV/o~x-KSVP +Ʈ h NPw;W;YϻE27cAym ȇL>&pv>W?ڶ9 +m4b? |3WlqV&C,v 4C'`|sf^`{@(lxGj*zY7liPs0e; `Un +b l>ԋ0դs/ pA+m+}Ov  xM_j9`G9p +󃙬x]za8 5/d[>㉘شU*SXO3'wAbÇ6~01fa",AJUU{Uc1n-Q:-wޜhuC'4*Տ>ND}E#k| Y?*Rº.7.hdڻ|ś$޶Unw3а[贌/64y公GԪ~ 9-@=^,<!0GNOΩcJՕr|haX3fy6`ՃJP+-@yJG`; Q^$ re/W,R(,T3 s=gUrQ>jd!Yws-О=yb=`ˠ~wl9 z* Kmx'<CNE Acrߙ㟸{ v@7?NnعWӋ{`rدn_No~>ܽ9}9.g`2q}vWep^T}Q|9:fx1=y4Nd0'7ޙMYԹ*aށ_v_ꢵ|Je/h_q *ScGq^韃k-@ aX&x,9~;;pN8 ?sC@r2!&^4 ]Yc|uNK`l< +U* ,YƑh +m̞Z'qksQj+Q3ES +`_vy8rd\;(׏BXO y8zt"j0Ul>TًGme{1cs~=y +f}ܟ\fwoqgǪ Ȁ͖acwBWOjj9@ ˌ]  +$vU4n,+dtV֦Wӫo'7?T'9:?C2ɲ=.I4?c7^7/GoG_۰sLd xZ=i|}uAruOg_l MzT?0E1Y~_ +L:=o^O_>>Oz/w>ΞD˨̺w;n49x^\E}"V:,QjLoARztY} ?>k^Vϓ/C}q+q{o.wOi}܍GEmͮ:{wchp% Al0p~pGߌϿόNkoF_~Mo9\ﭞom~Sez?9^tez}st\?.ĝd|Ӥw<-,`mw?uW/08|au_]6Oǧ_Mn~.h]ϼ]|Z[\}şg׿Vu8/-hV={G5ګOFg.~w'/tc:~K +;xRqJVUW7ᘴV/Gg_Ugw{ZYz7G+Oyw L褜UsI%rΡ:<=9&'c ؘl6l168o}~H{ҽ h(Q B72GzL R8Hc|uyQ!ZR0??#nR>b6@Xר'f'[o'pMF !3˫ :Ȉh cjmJ}¡=`*^ 3B\_}R1xCJ^^n*-=_ )륉c\thm>5}v㲗My[YEk۱DksݛgǴ)Ϟn,/N\lmݨM5?WFm#>*Ξ^vM2 zgozMF_sFs3ުV]΂!e}ɮ/,T*>H6X_.&APxMש`YHtE-?: wVΑZJ[=[5G g\ +q]j:tؕ`AKY:6˧7ST":^OSl|\H4֩`Ppd'1/Q6 +fBL)F}ULsϧғgZu=A$̦{ +jv|\JqՁ}.!.Bn(?qSZaR%՗]7pSJnq|΅'VOo%\(1jKFuhq~~~P?j"Fp`6?%Eۋk'kt elbV.6W2n, 2)# 9& NT3v huZ}I&.Bn.9D?q!Pv>?"e`2^䍒9ӭBw3^iܨ`Cxm3^Kh-mUbHcC-,d&O\r'XmIQE.j|:7y$R[}'3ZabG;ז=P_NG:qI@o/6"xs0s2;LޘZ=He)ڒ2Jv.ڞؾ/?wQLLϝ> 0u,J=_ӂ.oS; Qty̍gXdgKg2|z3P>. 9pJrCh匦юkE \ޛ=_8Fȹl{6<{m}p{_~I<89%:dh(/OھTgF]̘Q#`nAnz;3Nԗp 3h]57Ƿv- 3ƸAfVn`yhqj$@Tpt|t :J@hP$򡚨2p@**>oaj*xc T9rOSL@P ?R= īKi1X:Tc#rn}T +SBz9ڻ gL^>DcEJԱܹ'׮["6,v7a ` 1@ L`BluB8 VB|ڨmipy^0" vVNזk2ܨm ԋp!t0:@U!6@t Ut5DCn庇3CBcCgD`(dHgpa!@'_|3h`HČ֭Lzʘ#@{ rxFL̨Ʌ9   x7V0Fkf$(JlʋQAsri|e< Q@|jŃl HXi!>lo%:;H" ]>hbp8iIO @6ޖ@dyx4xz'$+|QsPv/?jƜm:N/B%6TWօؤ́RDrloۆ*hbWjWll-_roGM c^F΅k@. X2 V+;yP0p&ӭBAOk><;sjj8*Q1cH%mrUbWj.] IJXZ>CeJ @dzYPNDp#D9@ 3"0.XtA AG*F{nXg~oĆAhL1Bf~R⢇= + wp a IvA)yޘ) JNa\8eBFbf6ّm=1yt 빩HeRQV+ +sNT+R_Z>1sMBdע9@7.jr:Y`+(Cbx`>P=NfWΖ'j`@lB|95XxIN pjmIaB: eqi",nʋj\3F%}WMiTOVi8>kyZa|̉0W}\|bR0SVe!a5rBAՋ |9o@C8 #1>ypP zRcřPyILg0Dr1;:fP>Pf:UP.$4bŊ VR|<9 v`*Cl8Q9U aC`d|;\\NO4V/iL PD*'xCM==̱bgǼ'(3bv=r=PՇyB( LO&; 3 Dw hk*ڜ,L,Nn9tK'˨Vm!TScm܄+!ƪi)Zu&g=zHcTlI,OPdƫ&G 7dC\jFi-tMHU`IbH\an)ҙɆv?fy)fr]JG*>^F0'Xt1]?(dknZ㍖AD ;F\N/,X^pk>.>Nײ52Q5hmNIG& +a[|SS~!"ˇ.Nn!OYc\}&;Â19p@I6@HV2RG×7OD`.Bfb54<*F )Rڍhug FDAjB\tP +VbCE6RJe9Yӳ]=7).1 ;@لi8űJ)]]1.;fG̐W %sט~T( dn+Sgo\;?/w77\TEQeS(IsBmYlM.έ~g9uf83qN0BGN㵉Bo9^ ҡgnO>Ͽ~/.K6]HpUUxooz_+QRQI)$JQmM/m8||zU~هc0@?Y/k,V&.BgiFg|ś-(O}_??g_wGm 9Յytwj}+w?r#}x/?ŗ~7>COU2n:`Aֺ7x_{>?y7|_yGxAhB"ҁGN^xƍk}_>O?}]/ߚG^ P:Fᰞ??ۿ^}|߾?ѧ|W~ы/^:vIV(.TR`6>09{cO~/?_OwϿ#^A9 +#f9`[6z=Ͻ{~߿.8g׿}>?|׹+&#>BE=F +Xena}/{w~r 3?7<ͯ{O~˔V6.2s8a 'ߺ^}@4__/}>|s? VjS4s|g?413pڙqg5$I?Վ8 +F0OvZژ;uֹ~ o9{=E3S@{>pF݃W6SF!p[\O?}g܉s9}DN=zF5&Յv}_O>_|/민~ꡇScG' endstream endobj 48 0 obj <>stream +S+ܼ/O^}߽˿sGn-ZJ=@V1!p@VIDRG<ԛG~?w<.\I9J*)LV؏(nX;d3doY,tTkFŅHH& Ԩo`$yǔ,*tC0R1bP*6bϼo. CY݄n5^qrgㄐ0ET㴬1 9<~ c\NNtzĂb̾QY\6+~̱F^ `i(bs}۝?yzEqNۧlH#,vF G1[G=0K΀sWmZ|^1^ x{NwmT>: e JQ= 9߲ע|` 3? y蠣$aJr z`67 !A^Pba'F0,Aq_4? 0IVoFghķ9dv [\fQh`vd7V`v+z;  \20id ־QQK"|Aq1e2<.I&TùF 1viq۽Ia} ?D K1fAֹQV +\60 4н̗dL +>6d2lz aN$`sƭVԏ@ޱ״gb^=!#ܹoН~P/!NxAz=> 6`|!lχk&@s^ -}cȨC"}/@0ِ}CN!2&}1>8kF=%>fŇ\#c> K+cq* A=ltpj`-@II2PIG6D9!={m:2)ZZ .Ò4Xqx{f\h<_ɂm(Qod+uVbq߰׃ȌCv'} hוD? 1!N(LiBގ6dFq`n@Dԋh)φ|4E?e6$>ލDuz8:x!=Onn/cle2\&v) b ;@,|qR.܇X43 Pǂ` ID(WG䘃1C Bv*{.D3D ڜÍ(11; 0c37nC&H'Rťv' e'=lAI;ݳ5>ԡĊQI>#yqT?bq/i͍xrb`XJ%] +>5Nςg*bҠׁ 8.E:sV88p&a)fUkmG6ƌDG\<J0#(7Ģù1&)hNx qBV}fv [ljG@1:F˯"t\I)UR)#@{zi x V`K3J掽a@4dx췹RH|#8 +eÕe2Ә\d4نMtLRbhN80-]0tf lcC@όxTV"92QXo@3YIs!J sRR&D&cA|L + Z I:U3ޱĩ`q ta"Ba +vZN}#n@:U5=/L<p-hof&&Fzu (oW^y7 i@+Pf)Z¥<"Rb3Z@{Z̹Sdh Z-͆Y{ҹO|4!6`91o4u!`^GN=do(9Rf./{0 \ \gCU07w kU /~8P7!AU59gDd{4X7lX],DHEL(PS+>* Oqw&62bx+0&l?,%$7AÙRu19)BNVa߀gnjqr4;si_"\ѩ$.`lyq!štVfC 3ǥpnd2v% T "ܠLf8Pʍn,7uAU<iqz"."@Fm>aԂ SJzB/- \*YBSzMHBg5wbнi3R=aFS9S VH&frJ~=!J.J ^4"I:mJ7\'] K Lm0{fgͧci RY wyI1Çk>]_8}dkOkLr-\x(ZrQF(` &f-En<+t@ڠ_4PKle.j&d( "Z~Trf'D ' aeĊx`y)1g6MDf|B)2D.(H0˳Z@.9}'vl[1dFM~`%lAC6Bals9GVf§I)-n@N1)cԷnGnjtG +p3R Fl8fc5;(J`iPy|b̭gDkEhlFKV=jy%29zr9]nOtac Î1/,)onhɮDs~&^)7¥"8..5wx`a{@J"lV + tu)?is<R8mI~s"OƤ +5Ɂ&q(snf{{NB~ϐ;(E;77Un'>ʅ܂'9}4bu^Hq`QTA(~GMO%k5(nv@.zGC"'LaՌ#/n74 BFQnZG +IL)ǫa9* +ܼRb57f1/(nXC%"L U@%TM֏}ýx**+Z\;ع_K>&6 q&-, 0^,BB6bB nsߘb|XXG'H k,D汛|`y!.,itlDDZ], xf#G<a6:ze:w荧+1d\xa0^ёYe#}.39)A٨;xȵY[dAl&' x(#EG  $+~[C# R~M- ++cpH6phߠ[Πİ{E?{yONl0 V 74itJڨC| +bӔƃ-@0XN_h2A*"eq1KFqjψ} +Z Jy 3q4Z_O909_o,_MϝΟa&DE$/ pE:؎U7I H!gX3)JD,&E$#% ԑDcr9wx;wS\oǣ&/(!ؐ &gpۤ\N1Ϡ%53[=j pa΄ک\{0{ +3].d>2dXd< ;,4bGF0t֥^:x9:p)…AxX3FRr4xx3W۽=U@\@=7%gQKTc/2;y!fq3&'a+oZ䁥;aW"5l#M ҁ1!{X{ڽP~#"2NF>Dǜ8%HO+Rr\̳e ͂=n`dHA L.CD0)O|L4 +RALoQ! 7 DᦀsgBNu6Kƀلm^!P[0Lʹrw8FxiqeC8sAF.>yo?Mj9PGn**K~"uװw)࣒|mfm@l/v_>:f*Hxyrb+F~_,\9)zCf ʆ(XX"$;@HX)^L>H{`p@׳2!71;p1 rpi9?QP}m^⡥ǧ?XY<w `8pQDL:i{ BkEc}v70ƾ1͘ݜ &Qj}4fEڋ0#ry(,|8*pFu<~nt=UÅ(1KJ9F-r O6qvCNU˧ D9RRTP07,&;b˅@ UaVE8 tZNSrV7ؤ,AGx!A|a ^X' ƥFPelLJH-(Jтk~nz6"U[I'ks0S=I-53.oƔ{P49I +J#tJObl>3fc%%-.\bz 'ATs}vg~Z(Raiюya^ 8 *gK΀H+Y`|0. iit[@jMfA BcJ *.(:;\+{ٌ :֪rb&_Aȉ9)>*,-/{3w( sd0}#@} @!RWFcsnHuA*H`P>|^^ >vbڻ^O{ 8|̆g`%FH8R/.7}rw$"%۝X8+u Fk8b fx% D&D?N{ ).MEW!@0`U4ΉPL`B`rJ0.cuX#[Yb<&D+UPJCT+M MvXr=X8Z9>sK/yYݸ`q2b|acހ@1PMd@rf{Bʉ蛜"*T\vS/~;{L j`BX + /,rxS7=a۸6,FZz1M6(*PMl ++;XuIOih뀘Pȥ-̓ܲ66qੁUOQ--Uzkrk-w7/Z\Ă-? +Kg&WIcĎ} B'(in&ʙDa +&CcV5/brfgv3'Fvr#!TH<*ˏ'?cVfJG2fwgrD sy3YSl>8 v3bijo1f6t)Ҭv˧8w0\NHq(fݍLm \ 7P*3Ϩ 1  6|B)9G(``IB*S|hM:ƀӆ@FJ622"äR`D-¸6#!UXe[d0 aKtaHܽ.\Rݥ[ջ`c،2r0KkA{U`*&cxqw޵[3<0}V +rfܴj9H7䱅{]k.y@& Z ?5^E1'̵ˢ + +S5w%B%^6;"*f1>O)Lc9l rX =g<-8Aٽt85ќ\;3wbV&ꊱar=(ҳ+'c b₂G8G1 rWu5C@h^8;g&h%y"\Mte8*.Ft:X0Pz*d6ycA2ؽ؞a˰) T]bqуd梇^F"'Q QQ+X(HQw{9`Wd ՅXVˬ`&;>r3-ecE0duJ(H%P ch(9er@e~́ف21ɎP8P\VbbTעU([=iU'vV; n.:Ulnzb<J ()kny8OF* G:DibGy@4 ̝ZUS+bzV.۬^B7B]9sc +ټmЦO 1x0kP6\}8u^]S\ݺ6y@:ՉCڒm$]NI؜(Q6Dlz~Ջ0vZ*1ARJc8Glzqxi}b*3u~&4jָF!M wܵ &cb|Ҫ* h vR08rzd PL6as./o.`!Fl>'CLK&NuNil`]鯜G`jfKY:ܐSja)7q2>0=O?X;rxF6T gq>:9X)6:t֋FMv v X,2JR2hd;YNd>pcRP~9YȴMl\P9)FۑDgZOQ\/,nZ~L\:Ј[u{MȰ ^MIi`6w'(Fu=9ZzCמv;R5O0ʥ16+[3 yPU [ׯ5,Qͫz8‚qL."kJ +\|`Fo|kp2|Vjv_EWP]u~֏I@*%N4۝CT)eETYsq>RE4JT`n %xa V Ӎr4t &7WX>m1= ^<6r7Mgt&Y(MR9Buh0޴91 ;,iy*eF1wcu2ܜ*Nv.C&eHCf4==s/&e;ɣZv&l2^yKTGu+ݶX9ܜs9P +3+Ŝ T$+ZѶvm-w3qf޼ff/@\s9q(0#؄Bn9TRϩS)lګ0rDs5J''RVx77i0X/ ؠG78ϱz,_Ig#cL*gTlzj2ef' ;[~ǻS@j*Z'^/ڻVe P5aL?91Q)nW +]L6U][izVHg @~ +yCQ=a+;4V7c~|Cvp-ݩ bf lJKN|VӜZCH; x +`eC~qpr 1A+fAOOoΟ}ƅ^Hڌ :&z6!t*:dyYU>< +)(fBP W0HCagScS P) \d0c\ MV.<ߊ +Y g!& 9JLm1K!Af[,Ni)nhEmw^0[;^K/ +joAfV9FTkoyi_?36|#9]h .<:džCC^"F|@0rGw_»0e8Unjvq|j0*Aٕ%T-F8¥AhaF#\-0e fhnv^SR7nϾu<)c(FeoX`Ćˎ +VNDkq~2qu{‹kG{'_Ϗ^& I/z#i$ x|Rk!H?~RO_3'Ox;QOx؇qZJwNGHa4W03+S[Mm??8/~y\r)ܶgh{TAFyu t<P=āKVy67|ʅ{7^oJqeov8t1(Z蜯^ +K $:0H!Gb|ʯfm4čXO E !ֈ3e0) x'X+37U;Żs[G0d!'*x1^.q?Dba<nrW֞Gp`[Fesy#֚{J"- Z}>պ"'zAҨrx|sHq jsz{}j~/88$2eξ* +fuCAsV[`=XTkK8„4f#&ٕLm pncpwrsNe1J + ^~ŷY0iBp#Uw,_}{!υ` 6$ҽç[7[] "Z}ppsou\j +#zF/{p֪ir;R#رSޓap4i~4H@%0kf6;xMɕYz&׹T_6EI1bm118}!H~wQ`ļL(@ + ԩGq"3̥ɕ AZ6s6^Ay3wnrQY 3h)_"8IEuI1MбLi(0#/, b~BI l*ULupjQX?|#ݭSzG7^Nm +iX\0#yݧn>aADNdr}u=,7&hB!׳P:5_& `HASR}V\};ϽD0իjj&Y[_عs{+\,7p[ώ?!X؂/b<{D73#o0y*p0I1ǚN{Y*Ue>{>{?[joW螹'8Lqqc= s>Xc#X)q1ۥ/…I1ʱ&㩍[+R1uxLn-)@M|x3ؐr3~Ƃt2^ J ؎p9jhXf&LU|0GAꡃ[]n}x=ݏ9j[K8y~É.gׁc]ܸ QDxsӌq6śT|DdZɤgX+@&$DR\xa^vj( +#G=Lc-S]3i`&(cICA˟ IVsɕfq !b$kϬLDR PaHz2eɮ~qo`6X.Dn>|(p +Gj(~S{(a"NUbt \Ahw7cSA춑]3ۜ :*RJZ/Ϟ+tҬ[svUgwHWw`U4%I˜ "O ͝[.?_[0|Wo )%;:o7sm)uw=.b_Lue{WZ>zq:z{_xD IQ%ĦشlZsݗ--T_:^={+ދWW +޿3_.\sm^텵AE#bPSV-BʓzRx1*LDb{@UcVVbr +kh xZLf/šAgA"s&$8$ n mP`T1w~St(g cÂ&lRT7|=~* Lϴ+ghÔ%{38*'OO? +u()c&z܋?s{b$<%O2r +e,‚UZ$;YL{֮ZJ4yZQ& +@ۼEj +z XqdRl~Xaa͢Qϵ;K<{뇯>ڹʹskJ'&sWɩo._۹[]hi. +[޼t(Y>5 ++-?﮽$y}sKL/.E)5mO=w3g_ +'g'.9z!RՅznac^+JNK9wIlz6/N-&iJӇJf5Bf./&fX +77-Q?Ct >1:Y "&QJ !,[@1Zc1VԜ/2Znav2<2z55NF`ű v5Lť'9IYNL q% +}x XJW ȂQ^)¹w_6'W>;;^Yۻ/u/zkrnk΃ds]'Ѽޙ'Q0Iv0sDAI=yCթA-?l{vfcf_w>a!+Ҩ^ݼNl;O+?57}V1٦Jr)0'5 THIVsڍ?\0JF5T@H,hiX6ۻ$$P1z]@k2)+?{#vT0p AcqlKM"|sO~)5;'*l/)zb! +f V~1V=c0v3ւ%2Sl)B RTV QG@{f0:EsR{㬖@$7m$w2#$@\qJNe D5D:QLU|٤ #;͙eȗb@xC$mj,(mNMhfҜo̮{ n'U)r*婵݃{oOfRJq ,n]uV3JWT}kBLkov v>V|갶G_x^67y7 M>4v˳Wx/^^ZϿ HUރryr_[@P ԅ/_~{ͥrrҭ7CSC1) ^]Q RR I饕78Ӿ|]+s~z Ji#w~%K>6gG[cNHLI,F1rP2ZR>7B%'`tWQb&}>!&:ve?7sWaRKLߏ>^qG"<9@L 3" OPV``~ƋFDCà g<(?i1fQ|P +\R17%X&k1\wxCD9RbR_8wvEKLƲm=A84c 2CH(.E L iu +D$M2C&'l +d^)NB;vÛY^KhV´DɈi^-B S#QГ֤'ۓ ӛ7Sc,c@R(gd\y80AYČY-$-^-`i^􏲽򢞛%$NwoZo]j#<^0K|ttǏ,eʽ /ydp"JͩB @K7Q( L>~Y 0|caT*18ŒOL"R$@(-%=!9B.Ron5}ήh(*uANBt(̢Sqh)J*FZl[ 4C 5s*'n8Rb 0$&3g;L|})`jDD%OIݩ|ST1b%,9 J-`\Zu;F|`@P @L I1(!)#Iuy}hE36%9G$b0%qNI0-REH+)Z߸pl!a Ff|fC!Ǣ ވ"ᔅs)VBAC˜۽v{aJ md4<2M3V2l3=Tx=%6'Y()yNz)1+5-43ѪNq< 2Bx +վRB(1C4NgB %wwN{БȨlO LaxLh=@}јۼO75(JX ̄e +@Eଽ](U4(!8 Tyt @`h!!9TN5|x0(8jʒv˜ R$b A/?:A0bB 0ĄD[4p= D )մ`6#pȈ?!L/+O.POyhv~ws8mz|H0@@N]%hS“8GS⠀< CJHJH`0aAgGǃ>11|?1WGRR@ҥoQ Y6C!)҄LGXr`c3όy(?~|4VtA<=:F8C,yvj$̉o>qp@624f|5-@`IR*"lr jۯ}/"_8yj$ #iYQ2]s5ca_Έ[㠴8F00dQVQ@X4b5{Eddዀp"9+hmX("3rbL !z3PBIPj$3.{N(JjZ +~p`D1`!uST8)<>~7eQ0KBkJw]GÁ 1IBI$L ` tׁ "pn**F#CccOa ~H`u=`ѵr֭Rn^sGǁ F,i߳ GqRR0Ѻ݂b`QE%Ƣǎ:11> 1sAzTFA#Ӿ'D9 hHshPd| ?~"t0ɠ/ ÉTCuՅ„CPT Nx 0OT A7׊/W 7WF嵭3ɪ,jB8~|hb̋!S]IO΀ֺz1mV7E#fҵ}k+o)Wã tjhT\] WdWw2Ol?wW|O?xᙕR*H +Gpn%pf.iv53e `( +Zب۽ U`/NOla\Hij8ٵRHB0$JRtR5bT>adyxhFQa%RB\(YbdUvf|{O<}xε3kP SP %X-MWnXlbsg飧{_џ~oε%]Qd0 Uev+*R3WblZ,L嶖;w^tƙ_8|᭣sZZ&dG}=><N;y|tl P sD>Nr.>?5ʄ; T<={w4!EJh@ q<)yia^Xw^|y7?w\?@hcpJq|_{b~B_x/}ù.~ɍC`V$&*޴[,w_{O_W~K _~߿^yʽs݅|st3 OZtzxܓ ?yʟ~ϟ?|aO_|lLyC!@,"a?0qbߟ1.-9.wG|nn6"H +ETLz.4;+_y7ϿϿ{g/}ֵ|6&Id &ljŰ"t#o./'tOG._?~Žɭt A FW+^vm/]*~hgo?~/z?ɗ>8ZS.jK;5?~x7w~/p?~;Û_rof/*ˍNݻ퟼/_}~?}>g7/?i#j=kD#4i4We B/Ψw7GG_]yoW;?~;I#PLp'K}^ۇy߿_^|R)e2I`z".E Z* ;G{yc_~GO|^۽~n(V43#(2QLdh-TxQI\߹8}~я^?g^=rvLL V0U1iN[v]bR\/tg/s_֟x/x_~pſ}?~tG/K/VD aө)Mrt)+ݜ^>Ƚvǫw~zӧ>\^Lä9ReBJδkS7ӯ^>8yw+~~߿+[? ɥ슰$0δmgpZ|t_|g^{R5e倷1AJpBRѲV2rL-Z#~q6ww^啯?==k?xa{/W35)FXLat @ !982nT s_zO_<+krB (F4Y`r8ω `){e5`Gg>ywj&>#LBQ>Nΐ͔u~uo/O>~歽յW3 !R,r&jČ#6Gj.6;X,];:ZfnNGcQDho#@8,+d6{Ά}OV_wW.{mꢙy- +(hn@ύu9WwO߾O~W/^۪,wⶥJZF6Fr6`Lj9-[Tph Yh!}gw|2USJ+8( 9($cHK)JNAg +U4Z\tH$V8 0Øw0 ɀG")TiОɫeZq!]Y*ɞ*5m/GFOƃQXx/ +;,ӄIW*ilYWo̼ugхsjF`Dͼ[,,)QhMWtE E +Ut+B4@G&0:(Y D^骓Nr +SO9Ll$""Q&a}[+jGY 4j7'3xA](щ &T崪lT(_)qKO8NwzJk^Ωh8r/4(0A {IsJ6ۖ9&.a[Т +t=sxܬuIq0%nMD1urěkؙ\R̪@"(ttht"/J9)E G x‡T8*Fp\xp Pcx%LIFCq  4"juHU8Q`\s%; BSLgv?WZ"ɖ$$ʬWT XdPٴv'Դ떧t=JHNí%QRRUw4NG,5ČƧ99ۘchD2C0 $!+g2˜ Ҡx a]ٮjnSJNJIVHO3Fj;U9e?"RFJMEt$cMx;1y椗"Wr[5 +y"LیZ!(b̮&o5j  g5 &%;nYZyS/Ixau +׃ZgCy]K y $R1BBuVg7qJPi/d#XQaDctQ0/J`̦UYwRGZ9frZi)@tOXƕFکq\?čz ,T @I%D4 cR bSѡ10 FEX.RM1FfшA;+V|˒bJԈ8`: 氇Dn|hRb#44 +=s7:t0"Fɸx9kb-A4©!ӛZ'Φ4|2̸jzZI ؙl)ٞW#~\.b\:8wKCR5fK rbG q֔VؖҫނL|6&* *F1r Nn~ʪZi=~8q/vDfz3۷} ĂURKVe#ºR&Vvvtڴh,? ,-NX <82.L'^8)JÃ. +0GlwD_A0L\o3iMj6DAHkL)XTAmOj9kaHugwܲU\ ++A'YK dr,e1>?"tW+ӡ.<,ޝ0A1bz0&,f}XFny4a5L$QHk-ѝFl"*h6*'J>{ı7(4%}RLoH`?u b$xkNvFJLABJR]J+$fuvwridz6B')!b +^3^@v:Rj>ӻyp+$rHz3+}dlq|hJ\lƪYl);%0iz62+z~UJr9%:[wGB,mTd(7{gӲn}ܕ'|j6 *yio,?LoNCtJ.߶bb6;usAҦ2mOɾ^28즹8EHSߣc3Ӈ*tuHGBRJXɉM;,GSԐ_" ;:.\}OLNqȤNjZm 15XyX@<5GTL̊'(yċ:F"*nNUg3BCcP7(xOz!;@$YDG(،^8)Iab4Zli_?ky=ӍS-TViQ7d2v-;fg)5%N^L<"%̺)%ʚXnli8=Ljubvw+=/&;AU@ OT7%-5[;>fy.?sx+9 YDȆRr^*NZ[Gԙz=gX?9Tr+jv@J}9 ЙPi{oyi/ ˽3O4H)2 0ZZsz#69(R[/c%!2͹ V|q\4f%*AڇۨVK{SgT篰fy X{M̒Vncx5B&T*;ӥc(兝!JIk%tB D(E4r=rjFt) +vL\}XзkKU/tkSo ͵oްr[z$Y6"cQkލwf_6cXSdۅ󅙋fmKjYҬPv*> bIJǛ@B2'ڹX0kVpy40WxxHg+}1r*k#^SvoK)r7&p)H.ڵEiӋtO8ܵ &ԜVn{\BI 5TFǩm3l4'lIƮN wԲ[5 +Kj*U]c Jjܡ]2l-^n(B .,X垒_[lrEH3HʵKs4hm`XVvaev׮<`i}N{${yy#3[R=ԥ/ l|';}x#~ȼ f*y9,dkP0U9=>8+'!/"-Lc}a)wBP ?,=j "#!.Y r nu-Ѯ'k3v_O_P2ӜQϜ-ybu87c]ʛux[J^bӘZIT6[xs.^h_ܹyWF7p֊nyiӍKwoo>HuN'a llʩn\u s6 +@(:u!$S+ov6sk7qWjvYtqzKW… + e7WCtXcLHpHjV7 a~#ӌZ:@#8*fT(XBvV׮څY0;t}eŭ;/Nxu_..t|={q<d('AIYH@j*GGߩ<2'ɳV}0z#~Y0͙\ +" 0ll1;R !Ω$VS4?ڢT֫Zv80\:B%kN$X񱎞_=B/ Ur^ӳ YXV+V xީ1>u.f;X qXv*%3w7^l=_bBbbBAuU(lH6l#]$W9#d{$H'^b kq[&|/BU޿SWU!6)噕uoMe/XG$7:x>\=6 +\(o=%flyr7>y@G$23[\Ok;o|7ba4bQ_4wC l΃xsYZ۹g8¡@jMa͇QzrOt&X jF~!撕+; Y6hK& vq fӣ>y00BwykR1]o8'YA}0Fs'h!5Jbŭtʊ[Z/,{&C_b̶]4@`01 R`@N7ı.kB%A9Zr$iWɩʌhZk-3"3+ui]]]35۳;챱/H3i;߅<Y.y 1l,BE_ Ϗ|C(iWI l{tHVO?}>zkB0w_v |pE2DTtN4;p@FOJB~!.Nk\=}aR~Z~#EsؒպF?=|qs3~MvFӡRV'y[!*eVa17h}<|M8xXK?sf1]7 +ϥ? +雿Lk}l>?77?N>/_i|:uYc_}?b׾G_Woy>x/J:}_haп6q{|~[9qc\lƋWμl?];y+ślT#9{ݛjjwAp.>1ޘ6gN+5n$wݚL?ڧ?j4A_O8 2> *<sJieI &D\_}ۢ5tevZb9m_.%ڄY!M_}7˫dwAϿIfs@kmKF}N?߼| tQz'/%wz 3?v{j&o鍨_|x^wzGR'~$P5R`U&'G(m8i!txO ׼3I:;'OAKyKBJ)փ Hqc_ϯ~0[50@W;CCM Ϻj8qxvNusq?"؈FfLYk 2tv|A=)\2Z_tNnpP w'J͍p8|5;+?`Y-dpBkuڬsҨز[Nna@p +pE> +G TmN1.W>{ZNEGp:K]\`S>b>޿j!l(|(˟as܅I٥3c\pN(? z r%-o}}dG >㵁1`6?r| oOA lÅ7/?8}m='ͫOP6.#:B35NEn-Ǔӯ)+ ~q{]f+ 0?{w?}?֢ F8ԃ t@cN#?#ZbКoL+`@Je(0>dm !Rv7Wu6NxX4`=KH3iޤp Ywu Qe7;|)!!Z@?ksx%d¹AfgWQQ@bCJԹO>'O))]?& +:F3CSB,;33栚QkۯͭtiNYlk frJ(0RiԿ&^xw7EcpJL;J +@Pbt xsZI@9P\X#-\h^Z`6ʊ7M8Ƿ`g?#'\g? +m޼9}mg~jm|NW1j epp?n9k#+j$-;;J'/O~㵏k`pozopN X!pZIC3H_{a28^?^Q1}&ShgGٛ֙@pr_$wV䊨Tw`Y%ZN -ֵN]gZwr~Cg'SNMdo|4= +'qibL䤻e<|=w|oˆ>.zppu4&|zMmKBH%gK9 8շײ3zG۹;rW닯 5o׼ʊg5jPp`59 +ךr##4=u=v!%ofo{--ο4@y}]4&hN:_k0nh{bm{ڡ^V,NsH,8Wc:\#h8q2 SF[o*+IWE'e +g|OkPw Do)k5=g*\͏|~v75Xg#;/nr<@HYQ=LUC)8)=;.\+@Vsm{0ڣkaulY5Ues~sgo^RQZnjZܟ69dlr 5?g8^Mâ,pˏ@-{0?Bג3Yk#e՛fDO{(l͛R>1I1)jtL;րUr-XeRHi9ouh ^i@!\*9#dK8RnB*c[u*$6kQ:ǔP?=Zhj8CFk|pD'śz)7r2rv:QET i3j,9-ٟڭ9$FKٴ[NP4;v4_}wN9w +gA4<F+% +֯}.B3h2SqiJ0Jb(lS/?u> 90zMdgGu{ 31:(_]X{+=JK/ɻ5M\m1j'j~G`xc":2E+Jķ(q}ETӷ߻Rrљ)2_kuL+:a6ۨ gMAᾬ庙[{mN*=Zgb\V۩**hpZm?Qh1.10&֜ۿIVoCZkn9袎'#xv1ńQ +z0n/OON>}Oc'Eo⃶=ev`A'V̭ԚOaf똥ӠyRN;BQ0 lkdp=5!9#Ak:$ 7ߘJ' Ac#ZɮAqd  Eݽ_e0zFʱ1JfT:6 +iuFs1H/ NC`U۔*.%uwTaT2 (eQ6ጡoHOJ-=9}5>DqC~cNN$5[3?qjzDY:b 31Vmyt&=o5A/V2HJ"hgPeq֯b#`ONmEE6 c\쀈?Himvv +tUk [NE L{^RH ,J0ڮj5hƉ`|:[=mJqKwceG`(*6JA2լ%2@T'ۘ q!E(g2BxrCӜp5g0փ]ٞr0ӟ϶Jf1MKQqE)mv0\2;eZP\3lFsCǃk Iw8=|#W3D<qYj.nt)]CB`Meee {5?|t -} qAmŃ^֌HRgЉ@NrLx }qex0? ꘷~2\ljj#3949%GU\5ݨw!HdFd p/c}nF+cE5%кjlcqSm5ѵhv~ߜw>.M0(9S[`Sʺ;cŴuA avX*\,# XպNGCݛ.%;UaU;ZE2bh9 }M=).A4P? .3]7^sJTjVMLX z tokp{cH RDʶ1i("ꟛɒs\pM l6LEq{RKB wyOBVJLG7U)"D@0Xכ*n7Y>$)P8ɔ@aI օb`T6:v4ϊ!)Y{pIj.rƪ;N*c};{cAL&"uAnZ(4a8ǂ88}k 1I0>g.OQSc6(_΍_z+]*c a2aѾ7~JI"q7p|5-Xނ1o^H>v<<V /C5gV~i b0w1v*/ +97q]HIW'J_.=U60BMw_7p|xRǎ ).Ut~Mq[f 즺cKJU:FI9=bxά!$ ƇA؀.W]'1%ff;M{6;(ߢ~P&`4C= Fb] * . ̫!+FSs]K\zL/osbXR1@@1T˜:z+]a׀C`p*] 9W uXP\&NsC9.FzO^zk->%^ƹDjx 0lcUfCRWE0/G_1ztpIP{5Xh{>*8, ek1d죀*@ݕFsޫօ2"abcY:sm,$0'1enT{XBV}t +\ Rtۊӥ>I':i)s8kS䠻~ +]-v-m&ӻb$)ƪ;$PtGLq6r^\9qCFo֒e +$j\'9Lp=o*aeFP` !٫=8۾m)fB^x`ҽIܾͤ]7_/ €> CZ wy{ؠ*阒;=>.f2FsZl_1J 4!"BL` +xEkPB^BwanYcS$Slִ|A"Ȉ=B{PbBMNp`{ +&9oAt)X{ +lJ=`?\,\s3?\!W6Jiak85c:_Ǎj)J[`( ê=)Q5D-B jt޸S# Piŋtxq)&NH@kUn(ed n'*v7+VMeB7``-s(L4pwjE\QLv|rl㣭ش:i {&&ب2jP3j[|V_C;K/?*KaR7&5~ʄlkKpBkLTkp6&'r,0hWJ 1 l%%/#8PO% v1+NVk?v~vBJ)Cy.P c (`r 6.8u'FztW#'|*Т\>Pe9ֽQU[J!| +X ?8ٚVmrW]DSP,f9zς+==SV&p8\f Lo t@t%2ӆP]H(XY3{6ÃzEAB8R &գ (Մ*ѕZ]_5w_bc 9#U'MPV[j VQ{[\zFtԇ~J 5li2|>6!*U:8ao {_575%'tâkx7.Pp1tJDM y nf7(᪡2ʼnhEwVDK#ݗ M>b%JI]_WO Odvhv7j_=rZ']6o8{I*CٙIΘSPwUa[>ku @MV**@^HKOx81iEJYTqƕLfo$GzQ +iN8W(b~!1EgKٞC}jE!0 x1<уB$oGsxkC+:uieTZ, l [r`u!}>Am[SsZW WY5̧/`J:rhp +ޜ"0uM{>eܨS6)%ޅݻOAzt(R@T5XixsaV|@wԚ'e޹;i7( H֥WR {98 +)Hk]ۅVv@}p[Jt+}uO^?ٟ<'!g`!2yÛS-:O1 /FU.)qҵ=M{Fw'o(%P&F(@fM@ P| \a;ۧfQ TQnɁ ë-y'֞B^FRCȰF8~&Qskb"1INk8͍7՛@n3͛\aL`Ӿ2L: 0Qv $'IzП}a Ơ9n#L`$k[8ċ}5=ۗJq"Qz8=/3yª]p=>9ݓ_Ͽ Z-[=¸fMp~MC MN)8#pyL̞#yK^jGHx)nJ@uvEJmn H> Cv&5ʫ.ݗm2U*]H>Lj$wʊPưj;uQD/EP$2]/>}([ưU@[=v5q.ܮ +Rd~;nvRZł + :pF1),>-[ӫ3LlDPV 3F_*D,ixo39ZG6 %wYk_Ϭ! O8gIiWQ>1c#O*NMd@ w59"59{lNYgFBV@@rd7+Ww]hJˑց͔pJ;CʹUO7.j4D" +Si͓{w>#c8:&r| 6W_&\vx` Q/X2Y-X I:uٮU2@j֩ R\r)P-IIg7'sͿ`v)Fߠ*HeԪ5ٙg0_oۿ] O@K)΅2dͽpɳo;}#8>mJrn}Vz?eqADV.6Ihc5aqzSLwn<5H?3]ָtIoFQhP-k +TXFZX!ER}?Uq^\KgNj_z߫iQmPÝX"厕׈8gŧBJ^2(M7P')yc-. mp>ΥXV zX6Bt^[;6h?EٸTW+:$kB.Rӊ 7(U0 ?hF4]du.g +s%1fRx_Wߨkٯ$_%K\wԲ5c\߫:5mD'`-:%(NKuyă@Ŧ] +fۿ|k*D i;U{}koa = 侵ܷ0r@[{}koa = 侵ܷ0r@[{}koa = 侵ܷ0r@[{}koa = 侵ܷ0r@[{}koa = 侵ܷ0r@[{}koa = 侵ܷ0r@[{}koa M!-z/OǻrAx..D<4Kcqr<;}[H#5Ο|=fs1;\UaSU)_]%K?-oooﴎgGGw>aYyݧœW{ /BGy[l}P|D,xSu|M?^E/?\rzz7s.wJEOzlQ7`v7w)a:N(hCPUaG8:M<OA(Z!gt'2ڄfآh@(`)9RpyWqL m;9cNDoraS&7}9ޒqƸJj0-is֔:']G56nC7ŭS͟{S%ZqG*!ǜݪ6kֱ}_1u1np<ޥ>h(9`&Uʪb +F3;U\++naP!r!nؾvgүajr.Uo0Bٲ5V`BE.Taܽ?N0s&"QޘڈeܪS.h)CNlL +3)ʆEJk9sJjU}RbjF!'g8 Li)G 735&]9v^%ܠ} P+ե2|LmaRKmVARZ^TCrAb|\a:iW*g ]Mtqگgi) 0&ԑj+SF6L;&pehâvk%d+wE}hF\ٓ䍝QQKlrЀi:* *'t'CXsA΂;ef\f…Dpޥ!tYfׄ0tZ_Zާv9SE WeG@C5h1Uhk`lN@3Q}B:=$XOx#p;9xGi(p[ ^C~Z'tV(ydJR) "<⍥ټvc9ܫv ˨(,98B$K`tKUnUTq.f0EU(?.TCW Fu0<^Ԙn9uH.5BS?G3BjTV>БdmsWq %X٩`o[U@\$+yǥ:k + MJ@t1ZҺУC+>%Ė?oa!k8'2'т=%lUx}$Yƭ6SLY{,+;; W(QROjt'_TTe:~R[Wܥl.h_Xzfz^WeAoHAb${RD4Z@ +ZQD &FcchNW*ÉҶS&buwWvGOb 1):5NPp퀎-jsɚW}Ew?:P3KZ +2 j@3&Zc=ś3]BtYEILB}ٞBDvqkt_ݺ/ F[ +ч r <`ɆphАb.%uu*Ta\C O bS!GAAh#)ZbLӐLZx"0_eR] BjĀ. cCZn)vAil" +ANW!@aޠϬڇ`0 +kZ` +( j !:VکnѐkwHp +z2jPV{0 Tj +JCvC&'o`B +$dtZG3 J9s[C5~:R2fjTqǠ橕[䬖zMRU{ݣτx.8[}V3LnZj[Lr%ٛb`B0Yٱ׾,?: Em0e9E{jQ" @x 8nvןDk wpX RQ1F5FuE,PjȰ"SdXcehu 3Uv ^fʘg. f\2@5`0%` !@N A`7p3:4Hg0]b a|*n] +ְfAT瀄8m; opܐ!@pPNT1Ơ<DNChS{y넾*p߮>$\d"g00ĭ5jMn?n`XCRxCs).^.XcL+T^ op^ Zmf_ vEw"3jO{/ &@ WzvDǙ'Dʅ%g@3ژڰJEaWBh ʔ NIKj}7߾j&D6JM +C=$]G: +:cN`JZF $jlW![5IDI:J*#*BJt6snAkdP'=0>.+*>A@AlJp$%R]U]#MU%6p(hfzn!vyRAA6tL0?_Tbafn4U| +m>;pNBRb +4 !X! j[u憷P)@)[!B +x:}ٝح3bz֚цNv'3R%BNI%k>4 26 Ύ (>H6؀!4A>RK/B +(pkAJhQc +.98{hO1')x39\` zc٧s]`R ` ŞTvPXD0nAA>ŠLJZ'\H`C&L۪ +f|qa +vT0H!5RG/b:`.j* A1rݛZA!BN>Q\-r 0PAjxP]9:9 # .oS AXQmmUxm W +,@+M!@4Zu<,]jl1 Nq.+?  + aWI嵼J@V.LYUQ`0e@G3=-vuM"[)NL6?u=v1܂m/yoWE6  :MUƯew#ZI՛j퀱Lg-N|v8kN2YKv]c|A68 +Ԉ   Ԟ4BU!+uL0!$1AH(p0w ,`1k@рk([Uaif ꌃ ^ؠp9~2R&/asB`-Q!BЇRdA`с:i1jQi%DfSjC#v{Mli۝- -.%wpr 'ay/v i BQ7B0bOBFM[v ˨D +w +a P g+ + (DT%oT8"%X Poqk>:c-LelOb0+AzR.xpC4}MEFu +bVsڄe߼K{e ++V#Jh6/Yk` ȢxRx5ܼg:$ ?Z72ƇȓM@0pviA +Sck/cL3RmW!UڲH:?!>ƷTq:HK%{ɪ"wPTD21D̼ozcH?`7 +|*5MPI 6y(]3jd"쇉@Cr9=<sk]hc?^rZ*ׇOeIsu\]:s>3L΁ A@@ DWwZf鮮>axU|~ +NO0JZ\4)"~z279{:rQ#LiEj~1,a .&NyDX(n}*ـaTF,1IZ +%7] V z/l7xЎPqpI(3_|I7? bJ$ ֏TI*`ՄXӋGs|!ɏEAz.D]PfJ> @qHE 2o/jwSC@Bveh0i+9B l?xxE1xjxb#:l~7P +xGHbI'r/CO*hFBքȱ7'uekIT%(xZDQA8L"0Os椏N hȌ!6'&9.I>*Z kesza8!D{XR?p@a1A䂮^6@1 ,Ќ~T 3BX)BXCt;sP ; z!;r` BCv l5MYfE7cQ-zB5VH(nʸhLr vюB? :]c&O1~ |WۜTr{%k_9W3wC֒%sIA A6<ܢ'ɱ6)mn:=}rx`Myp$h^Nhp#7V%%qd NYFvLFKM}MyƬݠˉ \,@c2TVF gQPDVwԼL}V?"\j6g|sք1aLP&VTRZnL^~ +J>Tq< PP> 0 fgUIʨwY)=X)6k+ N9/p"Џc`l^7cYK6A4Y67n-|ݧBSg8m< j! A%f[<ܗ "Q'=d<(hI@}馱/}edԎ}_N *.z`{ȿ$|aN0P.}~p@;.yp~rxx^qRv7 * xv`=BA0bǍ`w[0# +8mdH!& B0w谛%'@Z Fk-0b+#7,|P̪MBi{0kIŏVwn -"T\ك$ZԱ <n!& 2fR(G +BAkLO/]\ra;Hb >y:5A6tF3G("&1>=Cqo@ךPQ;_|I;|­8h^P_vA+Fr67'$bFO@D8' l nE1lC,l;(x*g0*0qzw?胗30v'=! +6_č$;qɍzr qTv,"Z<w &1{|E CjppZ 7p@5#`@AA,xCp-$`K7e02Kc~vH +nb2 PhT!E#D!;;7 JcL^, #@m!DR}1dh= fJ۽ I/jOgB:;# ,HD)Y^R#8-*u"Q|HJ7㦁' +9v H;8%=9< 4NKO@%{n:Yd"!19@YD'8mTe$bc5ACPPgӛFzfEܴoA@ãa,jNvA>D06j-Zi&FAjmZ%]j n=V:z0(GrB=f ;T "7'a~cv!b~g"Uz[IOF `:)Hߒf\E 6;_i8 jrcA [N O$ h:03qQ@.bӚ@q PGײ "VȢlXn3i/Y[P +ib%{(%OaL v D+'a$+Ńku 8d,3ȶ6Ne؄(N:175 yȆaãsZ +62Dy@,8(_@ryi; ![́Q? )UP7=>/ʾ 6sP!9}##6,_,N5\B?xxN ; p.^"M@UJ@ |bA0\@`-YkX*{)zc^;tr4?G58!فΰA _J1&KG2*Ś9 n̈OB{׆=,t H :k (b1`,.Utrߨ( !:?Hguǁ7NjFhk м 8kֺ?v6"E` ƈuW&tkhxz.iVRZc T <8;h8+89AFZPn 䗂AYsi@C`H|R@Mm`A +q*?a. upVLxpzPpؿ +x_M}}a-[ IJx 7/T/'c=hqD(bBb eŠ{kc;#?rc- ZeIp0DMjBSA +.t C*}(hS tO% I㑢 QR/r#UBkJOe~r^A& |d3&bP9I7xo +}P؃֤%AURJ vl$~_ + +H@_`H K$lDDkgf̆=1Iv?6gm0a2^2c F]*'8 +Hq%ZQr\+-pvkUvCx +89q_Iu>4ǁ< 6eCE@U]~yc2@Ry, +(K+rōAd"ypѠ㮰w-ư >m-x8E$#<P|#Cb6ڒYVouH/r-H%*&HjfҔVg ,R0.e&olr]HMhahK֪c %/!lVWq&G֧qLmvrj]NKaH(TjaEe *ȜR cm d2˜_7@rp8P oI{1p9!b>"AU(_>oH9*9ځ(8ctLJ !@|K iSqZz+@A6#Lk $ #r=9+`0hL-Hg ؃QCI/6\*P=o;=LI m5?)|o H`Q 1<C* 7Hvqpv0 X7"FOD1>AF`h5&u? ʧFh[O&Ӕޠ +.Ya6Vb &֦5A&viS\`.hRfi!ƤL$J-ʅTnc6!ALhAJ>x 3(E6i[NǏ9;"y)%9s:tq@m !chq(-'0ӉDP3 #D'ս'X۴@PhRUNN&RG +> *ɚ=>9KKjnVH°O"68i)HrPߋVXG)1{zeI ]D$cU%7f,2(v#jx>7{a>3˴m4R qy?q>֊5W[bMxY:0tD!brmz :.kBbTV] +\VSFeV0jJ:>kZOKI +6O.(q)3m6R-.^̊H9PJ\gx LPJьJsǵVDGLHIDh$Pq7 bvU[aנo`W4|$ezMrɉ SsRzvrrԝH{Qs9hhdQ[Z6JY[L(P_4F}M.%.MR,xMں^^wX 'D$.&C{nzM-X=fJ4֠;aJKN.'O:a!Ţ$f2CՅ3#kRo̫x{G`pϧCld{ZtiJ.;Cwn]?uO EHWn72rONJ ̉PT`QUˏGsF}#>$CB!\}QK6?srvI.cJi }ez8RvdOV{;ݻ +[G-fa|8NgN5W.vn!"iR-u LLPJNΌ[@SǮl WSgSCk |n]._|MpP+;W\@}ϭ[ܩy>=):R4-xqopSFmcrkO4O\^۽De:w.?t6ֶkAUCHO箔.ESΟpgń+;)Ī;F+k!&=Z WDzoocXIwwFxhz6X{'=^P20! ".va|t)=y&Z$|wi)Zyט٫Om/db5{$^w,3boů-Bߍ5+ {rqANN;s  rz|p&ۜ];㟾^<-5w湇w 3p7ozv~b|8{O{qҝHbTWcު'W/ZeM.u0śz:U(9.9{2(p*t6ИHF?^11%>xg\-\(7Hi/7o̞VKHQ5 8p`V̄QZnm˩ʩs^N.W.BWNgvc|_|70`o5[JaW?g_-( *7u0srq7v RQaVs쵇x8YoqPA'"=q÷ +z: +"| dؼ *fHz*f$Ԍd]hD}pr؝)BbJ +qBL(nfo^Y>}Zgr%B|sG@ʓG YKRfrU2#핳b1ͻ8\=<Υq1(v~Ґ#PԒqp8*TwJꜜ0&kvx2m?sRr֬5R!:p"0)g&[>Bw7w]֊+Bv4sPzfDc5 +u6ioϵW.޻p|HӜ*)) wY).g 3jnصBs TA|j~);b'.kj2iRf۬ORKB&Dǔd0F Y)wNfG4dUؼ5S&mAmteTedc osSW%pD4EqoI5 p֯O)Vs+VAŤ|.Ż75.{p #Ub3q|thO\AcL3Z EB4hUOfY; w6]%=#4Vd̵KSGSG +ǩD7(b3}0uJJbP6 R ӑTD2Vl/O46ٳre\}La|[Ii%_w8=;U>Q/]Vjt4h;Ah9,kSǼgp! +H-AB+H'-[|{={ܨ,(H'Yphu5;ަz_$vX*D3f+a:mO\:C&F8כ;X~znXiYI $5iiF`))klk5.x(wT?V`vbɍk٩+ЌJ Q*[{X 8 b͉ 踟~Hj76o" y)dp=&kFkjD 4f$5-qzH(Q]S2Ka +9씘#RYp&|zBdarkyRR*Y[,\JwEs`B6b6rnMnYzGJ9}*ȍtHĊK\3h1/sZ2֭3N>_*Ϯl_i/ecMR-ÀxI#$’H&l.KŠTjC4`ryD: bq-Qַ451A(97mF$'**gq)I2u1BiM5f&A6f +O"x}+V쥻 Z$ jof}ʐ &330>^D|͛Fbٙ[zk3GD]xL,ry^#D hͥ&KN';G!/ d-^E? TXJ0n8!iQ +Jj&VʃFRD{S,s!BŴd2GEr1R$R+,ˀcъdqr >L#@a>dlmBgRslE ?|Ph9wn%&Z%Z/ySrꬔҨsAEOyElN+(-dΩ J*`\aގa2/9)3Sܨbbduօ#rw!܄Lj9R u|gAav"I͊K{\I&RG^Zt΀͈!ĥZm|53 -V͛1Q͆h"f$* 6 S[z$kfp7wӑB,=C#1 Ung\uxY gT8Bp0Zm{:+U|!EIvK汍}CO>ԳO?_?_磿|߽sGX>d1-7 Ot=?y׾y~7^x~#6\.y +.ZJj9-QfA;~wO~?Om~ʯ//-(%(g0bx.x:]m/l.>3}:;{<~/~w鎇&R*BjI..TvXI5fT{.?yw?zz_ȷmGw£()e)ڔob,v3jo5>q{||?0,sgU^X׀%n6DDLDi2u8՜M5_=xow?rϣOw|cW09HJ\$JWfT3U4o]Y;~3W~/~~{~طw4],x-32BÔAGҬW'wO||m_#O{3@G~)X:~ͷ\7|W~׷>O?ɫy7N\-T +#dj|g/]rG˯F^3?~7zϋ?cO|}lҙ-LX,_||~ǟ_> @^{n{a/VaҼʔ[ݩ]8;u|?|7~?_>|/4#Zfv&ƘNf>v涻o3=^w>?yÿ~^owNޞk +Ѧu8cǻեcGO_k?O=w>|O~?Cwo} ɷW܈pc}ؠhӛ{z>y7O_z蓿 0j^Y<|ԕ}G~?~7o|?_O>Ïއ=Twp&ᨵM RZR2L[lϮힾ|Cz?O_w?_}౧na#3Ω-MM.on={[}~៿_wyvBs+NaL5NG"l7Gc<|7o+|} ?{WoŝmŬR{6UڋK;[;{GvuW~ʫ?k?rއ}/_W{yh <WXQΩs8sĉSwuK/wMo{|}Gz^xի?PQ\h//bNRJ=|g?k_~yґsi!d$!'ZDЛ__XZ?q3?~/o'w?o^?ڣN{j'L=a9LVwW!='^xW|7~_?{wz-ߗMHzΏҞ %t#묮~_{͏ ' ?}~~|wwN|sAT %aӫg/r߃>+Ͽ _'ۛo|я._ut,7(uB;LjŹÇ;\rѮ+a(=>Z R{755={ȡ#ksᆱ=x׵_3WnZ^7Hf{7CDVR艓l.JʱWo>W|O~g~셫gVVLAKh1R*h>~w7K`affg}<_?O>yׯ'/~Ge= )$BnA!&QŨrGΗ_xÛ>sv#[d''H2R50`/(tәZۺv7{{O?O~|׿[_bi caϘ=lwbL2?q+z} }~y^|J!HSڬRT$)>|λ~'{z[훿/m~} w&'q#;4(Jޡ;o_|'?{'}g]߽􅈁3zנS|~4r)ЬMֲAorunuQ5o)qяPGB!ۍF1PFC$-R% '84/ifG?D9_ )xӂbQ"Bެ흺s8],BA'!Ə/-NN\TTd63Sd˗Jvorq=sɉNWuQJQ^ahfKQq#ktJ1ϧۛV-;55-6"%EKE2=^. ڜO2HAk|jw~'xl.NO.O*#Q[!G<#~xY>D/rFD51 WX6T:S٩rEȲ zCpC2`T0\TL"&ʍ٭kKT#as%#'iBbY%5_0%glXl.ьEѢnďhvpv9OcU*ŝ3(xKV)rZM6#H!$NQFGMdd/@K9qgD3DKEPv/EY?.F1&x*% !Ap58M2X9nD M4v2d̚.M*"'ąp' $k*zMkl$ϩe1X^ԓ6$-)mn*qP=^wZDIҬpqXs9ZnATUnoQ?m.9dRmzETK7NBix4tyIxC"NŕX'S[M>6'hzdž0 !LR12ӣ6tuA+P1ځVW(1.KWVtcA"forZq4VH>SiWƏ #7SJIJV $ZuBF^!Du]-,H+3ipɇlF>6iCn @0QwsH@]6E!mq`x1q.fL_BhO)Qt9>${T P)"R1&wߐ +;]a-dQ!aqP `/ J, TT RA;>f~ 2zn2YEYs 2fؤE yB#rfz.'ln 8},dF/|Nirj^;:9P[ۜCfԦ=TZOY11&% 'O5/BуhcNT+gP*~pK9Z*jE7[vy#fY%4)d#D;N7@2v'jØr.k#iNkJG⍧6T(gKV "պJypsE8FˆGaj01+epBe ݥ|cj͵16AJy(w)VT)!DjThN9gA\C-P #y>ՋrjrT$؇F@,XDH-~¡A">,g~p̏ǽbQBC<.#>ΏǘL0G\+$ |QMO+l#yw@sӼ`}C0i*iAp)>+yGD`D +a&S+gka.WF PblA >1xm{ʼn>ZFٔ+TN4:jg;ђP/=lhMk6kUW"Y_[Í)7Lڽ'z;{צwh!!7%Ji냩Cǯ<NTrA"˙'' n}I2)\a2a.'MB4i-G<դL)ĺfBOrA5x! :%Xu?h}A$g.-e> +(tGC\P:ޑbm)ޅیz^"!%b5N+:QqNRb+ \*z<'ď3] M^kFmB ;Q 0Wq lQK3[ta^XŸgd?^#ӁҌ(cAa*M)> +ǵ1T`!*o¨0vw{п/7梜^.4p[jN1&ˌo-]ͶѨHƜQʾnAh1.? i0aCu _ܺj7p m] ( +] XC!|!B1ETflo1)H"y÷훈T$7r f~!U^x {NNϝ/8MOWƏGCŚdb6SݝL778<ŧH.SLleD5cU{9DŜ~@;PA2i5;Ô1gbP 0 <ȷh4 Q^470IhE2\HEiq$?fjMpRQ t#L(3BN JPJR!tRfH+( w0T`/ ryKp!c(c<ŋGAqpl8hG>.(.]91!]hS=@*g. I^Q7sbPDJ,i$Ԙ $^-sJӬRJTR->;PKl43gs./I/ +ZO'XIB M|([.l N +`$LNA2!:<b4YpQW63qp>*kJ&҂u^- % Dx{GíX}D,vb|c(ƚ^eLmlvb/=DiuZ_|wǮ>#T0b!De3:F;jW!lJ1dhe5&03}U!4HQjWm^㛃^O#ZZ\rJZ.pQA:Z:!OPDh41NO @,mFf7(M[a> d$5kɉa.Kon? 0\+.&F7\'ahQ[P6`bTHk0!w$'f?=͉ s#UJUfbY2ZUr3L(hz@˥OJΦB$]p.+L S.ρ3bK+@.-?}yhi$Dَ$:h64r amJPJM/.h<ϥ줔6j+bjT{_??q9La遜8| |֋v7F+dk3\䴒UfhBfS3ȅ*Q +V-hnfOQQ$PoZrK[w dP.\DEz7T;wԤl[ tA:$'&6.]* gHHoVKŅHy +GJ+=1\t(yF6.T7S[)oZy~GUs'ce7!Vev8 Q;WL{3 `F,`Ν}^8D'BU"U}냟^qTjpb+8)N~ -J2x|@i8~vΣyR*%ֶ^rg.K (fQ~=hw/dBC}l!D{n+-`%eQZ\q8zf&A'#-\ )%'z֏#/;7uq^8"˩@ ŋ$6jWi1YJ7lRӄVI6'J)Fzkru%#@[Eb֔,ŁXjKL%ը&Sgq S3Ϭ4Xuk_ampa7MrXq7GÆBLgP2q)6 vo$hrrE IjjVXuF*kMe$(A•c].1.}:F"G\w9~:oM Vgø!c>LbΩߝ;W_"g]xx8w?WVG=BJ6`Hi^;!;*57b 5P#}n9>6AtA+ պ#N !9KG.T"4gjRG 6!$xUQi׌PYf0Z&;all^z:zMfa~rNcVj¥6:>$$I(1D(xm6}@8B[H1)S\ ri)9BkE̩dcujbsS~ar2/\dl "bE),D;|d Oj}Az c^ Jzv<1=qa~BnA2̙ǧnP?BBI^7lopa!"ޗ9?ap {#TXZ9|pX%RɃCBLfZch[x[99 EG@~SZ(4fjݝp~Fz˘RS٫yD)nqoFG_xA$rO [vaઆFG +]ǝgJfnܛ'TWoe"R@ţ;_hú*{#~Gb\o$0l>(g)? ;\hSFBdt^sg-9*D[||R, +oӥRC9ZszUhsl x &UހQ1]sd<ȠP~&TON)R-zTCy \jQsRaħ}xdAb2D@h泄! ف  p޲X;@*ù X:`p1 |Y*m1>!9R-N :DZ48rĉ#/9{/T +"8aE!,I5j楷_}xKNB!Tuâ3 G P[(+jY`X`:'6 :|F9p7:F+U j{if텽[kZc>ίB)I"0z 5qʅA2酵z! D?84#dDq1Hf!MjrtX;gV6!+f$EY7\u,3ްj #(HCjQ ,y8\V,=V ̨7_{81T 1h1)]_`,4x6f]/f/<4SKkg?ɩQK5%m^vp]&>6D0xCvӄФ낳 +*ucG`obO89DAH .DP59&4ֻ#ՉVŒQ\v"mB.Oo?lVh0~*? +XP|kurZ|l96P+ȦBA::\cZ! L7l8t+\Ä/|Cra:b0tBs~t1@6\{*lNM/y9\½br9G [ f@ #6P"XZgjs7 3UZV6HcdzZ.G.QL<ʖfã0%K0c7T`-XFl3JFvZ6@ +#f[dۚ@wB0 [C#^Sa:BkE>ڒ%S eff4ؼ( #n/ G탣Խftel`i`p#Y]S@vcQp`1 +nar ԏ(&)1Jzvp׃hdmGK`1Bρ +eӌXbĢBEJ,-}~@>L@֏  X0*@倣Ʌm%`.N(Xe#V_ŵ:뗲Z9ŋ]1RrClל>cwi#uF~*2l aSVui+o~jy3(0wzMOϧϖoʙYZNM91H|ƒ9 S/8,W  ^>DSڲRji~ *AZD¤%##8²z%OP01ԏj4H +kg"q\TsD4kMr:;s gq8 lHJnj`i~c6(]ƨ!LrŹͫJ՛!Q.A PĨa^饽8jGrQLςHKUf7ηW\kw_ZKnX6ꄐ 5F.7fs @I6JG_xq;/ 뷃h6\/F.7.9J4Ԍ^\w'vf7?m<#o"y=;v\MnCdͭ\HsxtPҌiǨ|{6_7T fa.e['[KWɱ +<Ɇ.&"S(#z>I@3|O>6 :IzzAIwsͅKJz2Z̶Ooz|G~i|}C53[bNBsRrmčyŏy}\ +O +ݻ86 9![oqx䒽j\YҚ9.QbnՁA OA:AEz{؀䃄x 3\+l1۞;RAV׋&fp:/KvpƝ/(<1h%iz|[ûg^3 Xڅ/~LnfW|W֤adkDƇG6vSAXFPU38kBN a/ ;<^"Ϝ~,BɲVCy~M3^]{Իd&JӇFn֝↸p|0Vo_;? +LwxXPY!j XUƴet*MR+庉6S%%Ղc5H:RhF +ޛ|O9hd>p(a (嬜]ݼRIOWOsf/ag:'7B|;RmlOra|NHg3TN(bTz/:H2TuDl}j17[!~[ ' X١QL ׄP!4/_5pZ7Rc0c\JON}SVC/%Y )ďv'|c5c^͂_2{wiɇk'ﷻcPAL{3P&dyɈV]n"nB1ȗG7(ʪpPG*4-|A}jm,,fP! )BRW0\$n`Xl)7 b97slmFײ'[ھǴ &=>vz咙eJ6KS'Eۨ w#BɩBkOMPmijH/Df/miQ:c̄:mn~w&ROa7fc%&>G0'Ӄvҋ: rb(;.pL>۹jU秕lWl.@CdQTڧH4v、:6dB';WM3MV9PM'/n^QMl + +$Iu!KDLzl+ٝݻy)x,>nLe1X/up.*g +SjuyA5S / C^/y>s;\o^`EȐlcxS5j~86FɋH>_xqAG$H ՓEZ^;-&gسU-wvy Fe[wk7X3GyR)%[IqF9{}!f^1"py&\\:|W-¨U CCZ-Of· ]`4.s֔b *gFa|qvMJ 1:-eK`_)XRg8mbՕxmEVs*$+|$ לAeą#{V` vC/g_:>7bG `[s(o{[.[k,흹˨i +Z+RJ*/ endstream endobj 49 0 obj <>stream +o>ܙ*,'a:^_FLz҅l{?*͈fuI =W.xޒ{p~Sm(B +Ӥ Pdj͟>~\,B@yϽ_N]BٴPb@V8(->N߼048A= Rl?;Tf'ǯS]":>sw go޸זϧgj -3q[pQy<^]3qG\j4e>daV`^1 o 6\.͞JOZՍ\L}z $.Hl+y1:.%fX+&H2i_s$]xbƄVV=H.?ջ<-Oi._sXyf.7]y{PeIJ|cݕU9 +qrqJJL4S ' !kf˴ "\LsurU[ O>4JӅT}[M9 q̥֠.:S{zə+ln܇4nuF)ݏ~s#`B15+X!&wa' (R`gfTS3n~BvqFUKM'kk~>wT*+ sdXbsw t퇥xfFFQ+c\F&MJJ F<+U,;CP2N߽Rw[N^zOY}/'7nV+S\X?N ލZbx]iDj,>0J!*ĆѱMW+ ՅVs38.gHkܴ rghǨ% +nR֪Do%aփ@af:usp);᪙n-\{^O|9-JIJ)V# .\<¨T}C|0)o¤ o^Kߞ޼+)VPBep[veόfK̔K=L08h%P:E67,>j.3PaV?z#&8B@z6cKr.?p)-` h&u۬ǀZ:  +q{ _ AT)!3Q}n9>!}^AQPj.dys|n7>`m3-4Dٝՙm%ik=1\hD9ZLS\BD  "4&ǀ3Rc''׮HV#Bw/ "d3J|"Hހ NE/ndƏO_̜гhJ8B[ Q:ArY%Cvt R-gb?.|#18*Z[ '^];,wvN?y}{fxp4s7cznRURɫw]8|t[?o.SKc+{ggz_8Gك7>3 ₜն&Vo^yoz1Vx%ۜ=ͺa+( 15WbP.nzQZZǛ&9s9y5hhPZitmᚚY PYKiE9E+y#QK)I!1YM,BK Q€XXh-p߹[v`Q `@k\z9#&}lR\/Hr +}NBR]T*r0mdo$Lm~dc69hG{3y#HGI%KI-L5V%RXaҡb51+[YFD#*NJ`ɱq%3GA|8>eaw>GB#A00fr!Agt۫7O˵#JMJSj7VX"5%R7-B+5!\"?"DWe&w#rbA/vj{|W;{j41pӋ~2]{}/ܞ?}[uL[ƭ'l\cr4õYJɈjѫhVdkwn7oqZę[/iRԱ̅ uOx/ ;(_&K`'6V-&\8mXM%"QpLLɉVc'?p.jrtTH@@ qF-z&vΈYL,kQ u`D8??6৤ +Kꣷ̷6y1dnWxEINFrlln%˂xG_ +ZSo^(3~WwwX$Dh|nN: atHBBJ#j`f"*陮Ҩqd-Ya9C> +bf`oA gxsx9Tp ֻCi} ٠H9p 9$zm&PɃF}3|\*H]I+\' +1˵I1!մO,謞hvHQ6 )cFh 9P|&øD@T gLq.n7TR-)ĒccJR׈~;o fVz8aFq'JiA+PbCSA'3aMg@5soNm^IOllj4q+? BMxX%E {dffA21R֨Lls*Z^0r3$Hqi5N/;~~ۅnCf΃g_}+nXR).ܳ~x{sxFY-7$z'ZtB@4@I_ @]:[ +^%{% $1r"1'&HTt7n.ѳKA6(b QIx02B1V Z.8$ +Rg` +."RaFl(x:\aRjf,t0k7 ?5 +9AMr:a$ MAwg~4HXhi-Ś$@% Ii}V$b0VG=CP ܄0f'xy:R03JRҳH^T^?)ckQ P&,2n?1?【a`KaD[PP%UOB%VJAn><QfoprP^ŪY}G%a:B*͋ p!&ӧob ,$c""B`S >Pa$WJl1GN!t pYY!  -JpWu#~%#D$7W?,·'<<Kh2A >uF̢3;=| /"t49ryqy~Xc:,$fĤhBtu>Q] tWiF(Z7 W6SV~j lA>ď9cWe4-g=A-@DA!Fa9:ADj[ LFC┉QRJȌPT^q;8CZZLd>ȑs:ёA`cxp֒T{*k6>G1? M8m0tFm/lN* !i/ cB*| 1\Lj#l9:7:2 Gީzd Њ!$)rI iמ~+8@"RL %$'/-m_° rw]` V +Ii x%OƄ gS+)j2V%!z\ Ź`DJVԛei=6DVIQb4~!{lb%`<(ෲ:q 8qT.N7@(QBZ '{SG! @R©͉&T4H yBS@^(Ȑ6 + ^,Ns  A;elRHF(Wo z|<{xo C#/}ay9NJaEsxA ]`$)lGGQ׃s|d=,z},NZ(AwMRҠ  búɐ͎ Ðd9<9P(z| s*LnҲĉΗdT h *H@_hj22DŽU^L-.GW? j+z,٬Խqn}baF,* 9"N fJ9tpb>]KEQFv6`a. NHpGd b#T"%zSC=Aã08tiJ35i$@BRf(q ]5<$]^8d`0e0R,YT5%BTdUF8x^Xl )`Oz <$& ܰBGs{/?>2A};}N +@ @Q]k((L@3Ғ(DPdG^?vttx;2 +mı>caN'P>w0!T1!$i:ʋ K}6&Q!ޘVlqRD:\o\?z|cY8b();6䰹P6E!.̅Tf0P4,l2&JdGwOݺ|Fu `m.KH\Ucu*٩y ̣[nl~ݍR**O EțdUtǓF&nل%}aU;c+kU +ۇz=7+- a$JB.>4!XFp̙d(jl,|0!h;B%$YNL,^aKalgxj{vne˛'wO\0=GH!#I,KN^KSUcZZ-~}'?>7_?xXɐ ! PE9.[nE¥H(?S Le'rǗ/?xkO|كj- +|n݁^ GZ.n@VJ!hGcF>/F2f :1c=9vu^=ٳk|;~÷^>2MiܐsDy%8Afjqapz3z#iyݛ_ųw2E yNWaTIQ d4x0}`孛ӯ_7?}7]gۯ߿F.1 rncJ3'|Y9ry"6\]ت={o};>)e& 9Fr !8!ٓk 7#~tc7/' +GpB1 s%Bq<S|O?׿|ǟ=/ϟ~;+n7U U-tv /w?_?~sן߽>SyJdG"s} rDl:lsW"nW__O.?}٥9&:O@`C kZVqb:3 9lzg~o>__Ϯ~??/Ou6d  4Fiȇ j2nkv>uz_.>6O1(%4%9j'Еqt?y;߿o|Ωwo/R&S$NH1B\QK⃽Oӏw?|Ώs=ݹ}~X+Vt3#61\b=TⱢFtA:5d}w<;kxlڵFhjc2G2+bFd*!b,)W|_~_y7߻ ſ?~Az"qCBf9e≦EL 9O?o|泻K˝2>Bf +IbJ7cӷ7ӯ|pyxkƯ?ݙ{ԹbYKȊ{<0@gFƼ ='6ǭn'}ad7.vud<5iDф}E9IUjɔZVbfrk[o|ݟveV~&G/a֭ ⬿֫6}g#A۝?o^×٥ZPÇjc"Q0Ikcܝ6>}zwj5Su:^PliT#>ҽgfGzcw$ɲ47Y]"keZk͵UFDj]JWgOwMOunXKX  30A n9}&׃(`p)D(e +]oΫ^`P=X^V_z +s{2n{k[Rem`Q,~Y^?w?÷ӿ/_o_u刲:(iqb)ƪ]j{ޝ߉_?o/տ;Z؆vk;\啐UCiבG0Ց'#h:&ѲLGVF.VtlJSYXV!Z;lƓf}VܱNkz`Uot5HJ%[3_KaXhY5Fw׃Gs㫋ˣ``Xz-j}XL"h$]G[!;X4˟8xq8ikΈsï)vS,W q-Lrf bckRu $TLNHNaZ, jU׎Z~3aHf4j#'4e͐U0*``RȦ AN8vEXյ,-3j~v{g9;j{^[[\E@:kLgbzcAR[L*ޱj[ܰ[bx}9x:ケܹ9yh\ q."ɲ [yfqJ=/;H5VClo7,&-Eep. izm].^6[G_}+JYDNnwaw֪uM&J4s\ +[D- q#I\k$b$JUʅ +("#"1TWq3eaJ͉q`F% +v>^"#XV2ksj(eEL|$pj{=n(h^SUbnrbOv5!56RͭrgJJ$_M4:r)F*F%Jϳ8)6Ǜ"qe\﷏29hROFh_ƒ5Wt^d^cШ+Z*Iv,?o~c}fR4},x\T6j0Tp|{+Cގ7~jCP=_R?QHSR}רI?ZY._JS[[H$@[P7LTY`XÍREUj%:Rf4:ռO-ԎwG/ Y h]\m*4`z/.*VZ"w])>6F*ȼޗa0Q2Yc.)60<§Bsk&++a&>ULgbI`:׊-I\NqV x/Yv VHE1'AuJ Tց:I~9Z&7~15gݷoٯLX;0Q\hH:J+!6H'zˤe "kY=xLig^Nlϗ~Uy{{Eǔ:Z? &{o=Ly ޞPpD:~}w?g +Lt/`m&gAơbM3*ta IF+ +CsB("F0L]IԦyqpntSe$6ƴ9aNh8iEJvwuпUt]TV3QPLi3jo+`bb=֞Ij|Id1k=uyK~x"S={']Kr ZΎPJeժ{P3y;/T(d_,W3!W1d&o|v+w#vƹT@4)5Q"`wWQ% !Ƃ3R;E;k2Gbe|LuTb'rum4O_?l]~Y9**5R*գ`,X> 3Bt7opNic֗o;/q +<8~"8^. bz^3ns!U0. +,jo?4*ޡ"E-5>XN{Ss@as^Iӻ;'Dn0<4{ekc5<O~uُhPfV֜7z?=Iޞ}_?=InA4{[E[J|L_N?kVoϏ_Ȇvn|+_צXJT rhsϟ/ο:r0~,E{b#;ڗ_ZNNk2҆iw۹I5N^^kfWsRX1f%Ē:-ZKEל? 볧'PA}?<'_ysG_\hr4{{dip3fFwgz8yY#u.Vh_@Lgxo/_>ZePw꫇9FVjSڞ:>RZj1щڼcw&$ݬ;zu.8{9yîk0J(-{aw([swPn\jYu'Kql$rt.[ֽo^0~{c5}@^xxU;Dhwޒ|jwwsVWa|}jvh3.HfofG:x3Gn4krB~c mL OkiC$@}9Te=''1}t'Ow>P]19Reu] 8\a0{Jvhv:ɽovHgDg.{ox7?6^Q*Dek"WwCH^qΘW]ua6]7?z{/~Z\kt%5/hOm]w n{?-/.}X4ǔ59yc;w$MfҖ))A-؛_~OZYm\!b:R"ޛ/7HxHWTnB1f}Ԛ]>x/_kGkקoxnd"8f,ʆM0.;fϣ`S%S拣W9Di&ZKm +I j9`6+s2 F[iaB\w:qGUkwn69wrluwv!eppr,`d#CUw~+{ɒL58z~~ÿX?]5 7emx^_ ވ^cv3`?Ν(^7؍U"?A"J}mw︽+46ҪK]ۻP;;`pܼ$I34W7tpݱ;ax?'BՁ/Eg?^QP!Y?ha}'Ex} hٝg}48^< +ʒNNQkLZutW;wz m/u:IB`)E b?wc@Iýt 1~`N)9jbqYԫ{N댷GwvSDkz\F8 ɟ);mL.A0yw œͺiB5Z'=~ICyR7:X\}# +p|4{[igIIw`x-yVeI_NG_,r[%|45tlq\V;1zlX;G7ߢB7hk{@ka?Cg~fƔ6Ϟ=mg(W3?z];^xǫ^! ^(ޏ/]gGm9ƴ>,vIu 7?2ԫe8}%3zPLAvyS]Ui3N6P &(Qnc5ٽowF^.xCNhR\,L/ *T_ w. p:gӯ~ Pj7Xo$b(eOE7Dĸ(?G'%VaNJy.3> SrMvZz{/yE$k9mT¹>Of?-knm5>xF* F R;b>_ .^/8%w5YӋoW 6_\(^I:^Ѭ^R)J(fr&1.#` {vL +]!-NoI%7zo`]yj .\j lh;0F[GgVh#o|&רT5 ścf)"7AvwEYưƄܒPA-~Yb|O%Ek{V.RIDT"Rڎ]_>SfSea3#\<FmRuy٥A7QMu~Јns W:9WޜH# +è9:~y@'ąmWwɭALȯtU Dc s@ؽ{j5\p+9X:cS4އviغdcCI^bRX`*qrz{-j5jtxE )M*]PXsw]tyfu}sL29@ثZ޺Úmsx-BsB&G5!xsT&{B1uWPb=OيJIU5V/o@%o3ng~~QE+R?=_.)1h"ֳQbb\Zǿ>kJHǬ=͞v#嶠;sZmRnp_vichS)\Ѫ2*]v|kي^-n2|+ʒᴌڼ,ChwP:zJRnjǰu(ص%7sBQ ֬:xО?ogux,!Ay%^"OZ2)V%E0n})q[YWZd2FRho錏ߞkor& + +\5Sy=:/%wY 0_tzP!͝hM(2y=i/mlY P(q6f|R^":ćPS2SJCP r" œjL; A@%Ԥ&kډ} WAx\p$ZNHhZS0݃=a:ovx>7AkKsYu%wU`&#;<|UalE1Yx&0pTl!Ph1$@rNb|eo@.6}jTVhlxACqsxW^0Ni +% up>;Go~NJGօݹ/z+k.gNUi}Uomj1F'm٪hH]`TEP Ip*VǩrsҒH{ޯn[޳d@"$] x B*pbnu&>CK5[F4e+M/$?e "#$)9.aPfaY1d 'TcF"1 "13ƚCI5G"\D#Mxí[i *Ĵ$ΉMIVDr"9Kv+Ջ/{'_6MJ e] @ 7l9"4To"q8{y\*(aXե. '8K^U'Ϭʘ-XVz(W.(9E}lpV 9*s!KTh@Nkٻ;Tsҙi O)֖ǂR'0G,~5ͪ-?^s&'ט؆Ҷ!t{"|F|Ī#]$9nXv&ٲL}vU'))T=8'vZvTPB5*d%QL2fczNRG!<=u%ynO.p'-1]%o tEpYkQ>ۿO9BZ~8:#iK(_W% +,HyoƤ#`]FbFq +6Gw['B9.3I +U; n`0U"a'E/GqrNhuY"|+EqL還hiXSZu'([KYW҇%&Ը"R[Lip =0JfSRrO{SKq9 +ocgv a6xп{O8:V?"6ewEƒBx荞wP -l`r6P+Eu`gxK$&xR!DҒi_s.4a/ dw*lT~fPfl]Jn䍭Byk-bρRhnSPr0zM(ǹKsN +.2-sœ+"R#OJl j~[oή6LPa"3\ƽS +l3La||kȲf0mMJ$X"`&o{4Q?/>!wiuhćFH vt*z'e>er @ޘR t_Unf"]+ yh4az,Sol2RBY=㇌'F+:,rJ|bji;ˉr7jg7s7o$zҊ7YS@z+G뭰y^=쬟ZmvÓWj\ihK;WQ*!3 *~"!RΝGOjJ$Q7TeT?B^0zXxم\haүT/eP&unѲ]|^{>>]ۄx*tUuWOk*I#+Z3}ouަˬy<:Yncsh{YԸ!)d;mf +DhK{L'ˬ9S5h0 k#^3j'S;2HeЄ +6.mqWMuڬNΑ߻sDL1xQy"tmY>Ȱ?Q d +eFYW6}G* @X P+(+4;% ~ʘce05b>b6jndRxWrFn:AYBz|̃EvYͩ)uMo*$!b.2ED{[ Af"Hc" e3G@jj9' +7S7Tp#nBt'ד͂Tm(LS^e(|>ǒ +Z#[dJT! +UZp#Uq-9+P\A\5JI7hf1Qff3S3%5{1B:2+3z8N69ae +@-5H.Xxء ceI0VbVm~vIܑew.o&ʉѽE$[]((:*`>L -Uyk׌Txg"Ug=in+LzӮ\, +o(5` ֓ux^/Y8pRB{0E:* 8^zunĈVJ#ʝ߀HP 'W>@DɹR";(u{ɒ¡6Se3(YdKR2"CD"J l`&+z8˒H!J@`9 :09vlv:0u*ɒAtTVdw<"fm+W':}%\ldB~Ć`uKʌqs+l`lBjTYfJ)X\YJeT/Wdj[q:O'\VTp7C~~3[&tyGs3E| ʀsuRlp&yɔ|Lza +n! a#Ͳr1F iBU)[i>l~qPs2"eLɊGpa,**Azrϰ`()G <\Aߊ1.!V,@zsh|OygKURǪAB%.v(m$sRkq޺5դ;.@фCǙ_ѩ`-{ E3鷷13dX51Q}9<պAHlL¤~]eoVbW77BYFra[A2ev^/@<0wm!n)̖ٗhx]z 0  DF#ZB.t)oK(4babւX`jOf(RrҺE/.. kMt5&]> X(e1FK ++9:ƕ.`8WPBZ?\qڧ:ˤ[tNPUکU^ e1_p6|v3;EJa@Eʁ. Dْ"+\y) +7nnn!lp:cgw׿DJ*D*I\&ZD jw`By)d6#C*$ K +fO+ItP6Ivrovf^jiDϔbLj(dQm[i~j\,@aj<]tZ17)vlc,]`(6ʀ=NhyTN]=]$]})f^~cHk|6ҨQ6ƄA묽E;gdrIha(UBv@td%`2ޚ`3BDg+JY#BCגkPP/9N\'VJmh=L{yEM0\Yi6ĵ9c9HevTA3&oVغ`IJ+PUZ'!a˄I +UY08gijv"^)H.FvEanPr[E4?5whNU5^ +#VE|2Br`wKΜr4/WٲZ&<[1\P?eF`P#0N,~1Ɍf٭2X2rA{74.yk!:;4.`ahL VbppkC1X|'yx5wRFO"iSjRH0.N"EekLj9\7?j p*RK")SnfVWbz[}^dY&tXsi֏` %0@H[vZu%7n}pvRbJkij+Ze+VlA 6@ T(êhV8@5Kn\v晢1-9{HodiWAy 5`dٙT9?#)@! ,]`ͪNqh.6@Fs|@J"ۄ(hrr)h_xHN%5=.#:D^w7JwrK M(gcѧ1KDAtEc| E6."U&!6lToAEdq/Q^5Ce`A%y=SA ,1irAx|O9H1@1Btf,OI I8 +(5*wO`Qɓy)Sy{L}|\ fyB FmmqFxІwJLVSV$#B OrjrLY1iO0*u rFlƘFzm'9TKIST3cHz?:Ĥ!k,[;o&:JGyChA a#ΘA}HxJno͂RR޳{ }nl76޻|92w &P|c+JL٠ "S-b\IM2c|c;'mela%wqa5nnaA0交+kNmVo4P G$ 4S%vU$B]Tĸx0~4rD# +AUDLi!w:q>d#2h2H1"v?EO@P#S$n$gG!Ps;Eݸ.Bvrok/p`?:_[ۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ|lۧ?Ww$y&xg/,]UgGFH}& $GwWW;v$ɦ)(Qn(̊:rHvg47:s* Va>󼏉m;m;m;m;m;m;m;m;m;m;m;m;m;m;m;m;m;m;m;m;m;m;m;m;mwe.w/P^,xwF[/d^I[;`ge}:qG K?t!2Y7 (t! + U_JSx +#R$EcHH&IւH4$B Qr`'{4'DB/.ߴӿ /_/,.T++y\*+#TF0"4p BB F\f^h/k.cD$.. ԅT_o;"h?O]@)؅ /1s@i$B()X=Fpz (c\R;գ +rYKÙML'4/7qLZ˲6qvIM(3jQp:JvEL9gDYmTmrMٙUb.!TXk@*5%I ~%p3zaS*K,,v6̘Z<\11˘mz(۾*sL&l<>6k;Fyө`=F6 J \6JQDXIXpBƘ-7H*-V>8NW)Rh4Et{ L *4汃˵8fd'zz@Y3;az$oYhzDQSjfRQz?Z +I\'ļ[v{ja.WTZ?|;!Wdo֎KKqNìSր2_=zz +q’?6O)O=/HG1}1.&H) ŅpRB,)W` ET(0 bP`: a*7HR W}K'I3)FI7$-Q.RB&Y*qqUQ((i +Sq+zWL1>w)F] (ᵖ krp6DO3B1=Fnj`!l$%PF Ie)U:EQL[CLv0qgf~&3^\JQb爵q>/"L//m)`@x4xf1ZBdZ,#OEJ`37 /J)Di]՛:mbtc[ǔY& kt3c !)U-7W Z"l(=)DPf +j0…2-5pޏ)L*zyQmqqhdRn,&Ջ;L}1շLrjf(m +1.7I}@- +F rPaD )g.DhJ!7GH;L|ԑV 2w +K(-D[i 4R0^cI&(잠7Yĉ6\pp*:ˬ1殩rTT?$X(EHM]q{RB 4\9*7RSxwi㈌iJB PPFnFB+ᔴPaG*P m6eFp>@v*6̴п&UZ KX6b 7`aH_x_-ƨ%i&VФr$*5qJR6It +p͟XʼnFCprv"}Jb9[ٔ*ۋaA Ğ֕ oj7`lFfCpՑ3+:@:ngUmIapP*1ʎfwQ^Mq'^)J*;R)w@J0e%@|[6 @V ֶ?fj8SK(௠2IE$j$P#CqeP'HRfp/HJIkBcf1<`Nrc }GtV8 BW 6" y!DT1>$3\srC)5ZQTO6.8{fjf g[@8fP,wŸZ"RJj@( A(BQ|\P9P3Fn75~Xj8+èP%=v/d =Vm F7X76"!KmFmf*!Eeƙ9!y,ղT6Nzʲ; gtr$F۴Rsuuμi G_k+!\Gxpbuf`.h%X~-AeT6IxA(E;D,U{(ڭq>ԒT7S蟯蒍05Z4%0Bׁ9 r`]Nb)/TR1)PLk(%.R)-}'L2b3\o?aO%x ǥp\N.X 3K1Hr +VPB P7x˼9Ac&x0.EKB#BRZH(ܨvF

-OMh䛻rfFtJv )졒H+(n)&'8ƸꭑR1Aݡ IE\J2/5owpbR S$jM;r* (RpOAN})BLPR]tVs)- +T2-F90! 1r"⸋3$TI +'eȳLcx:;8UpEڹQ:1~tV[)xq!s9Uj`cXCAC3#4"(>d@h6\.F!TSbTyҍ!2t1(̌@-V7ֶ-Ĺ. Mu[X 9IXZ +GpCàLFQ+)I +J,[}N7 @ $LHJA1K28qI>ykJiZU\v0 .abR猼UͻCT̛ rYL8jN`')1-xkRO2hSXnRS0[ r<).ē\(f׬5JoBR%'z^H +^Nk@)nb (uq) !8GCF()1g|Ԑ-DxSnAa +* T I !γR@ IpVTx~AL`6Y$lH!FJI`:\f1҆6c2mB5>_ArxJY0 2`{c ^rzq  `6!`LJq2,J,$fkn`lL =808kJ1F`Q8`-ȧ!H@tXtǤP4K8cơ6` 8H1oVTqv}2}!/,">o:B&C) mwqQr#R![50ZHk-ABtg-Vvի2F%\s[B# YX_+NSvlUR>Aa"(@%.HH, +q$ceHSC!܁M2C@` +"#|IS¥!AL+%d +Y0JEHC:BxI̅6b4+qʉ6\0Q 2õpR r()ESz8 4اp!" +OJ"&m_Dܥ%N@jp͝b{c1L$IN` L-Abg|/n& 512P*2-K5k# XL 69,7;ycbB}:XnВ!T6^ʓavhf+U\VkJ$ :Mu&pPSuŨxG3l`dgV[W(tO\!W3u#5<`Rsz/.ơX 2Nv3Ji/.&/pJ,A73 XP&P8] +!K1:ńC(]]T@GQJY40Q.΋KEt!LCAt`hC&źK)$FF;m4YB%(`w +T(.(Zh .bk "8SLy s@/+fh[ϡ,FiDb4MIa!B_>o| K\-A(Ӵ .Jh a +.Y 6)QDT XJ N fI1zJ '7+ N +㬋ip¸(ikQʠE_2Z\!"%pl}]% ʅ9FFOˬՎ3QK:.P+|| +I(8i+u]1)9 Qx\Qm깕8e.h)0NUkJFswK!\L +Ջ1 +X5#?B pNY8QHzg|f!al*cr8 1'['oW_\D/H(5  yu6^P F*7bJxP\]2*t$"*lҹq4v.-"qDH|S<)!5fiVMsEFU.H\ם"8'5ȋ@JC2I8P\2p8ȹ(WAK HlJ(8 ĕ#72M!3"2Tn63*i&g6HFnŬ)Le2R.ZʍPڏIь` +%9j5!b +f :&JsdOds-6CFUJQkn6RW4aǽBBF$ nu+]^WC9Q] Q,H03GEq43vt:& + +N%T($PUZJvDpoXF g +D-ăik9ȼ$T_&"jJN?4Gȕ`TpBf/-&#Q']k#TԆ] Ald|y qePPy9bR ۀ%Qq2!Zrb<a.(Ph"rYFj T&;M -&Q5PЛ8Γ&@y/2.6~}ݬb˸O6`l$x8M1@؜l4QK奤 Rpk!U$)B($sX6B5!QaHq6$ L8AbJ>m09EU-bbY R"e$V\?)@XCtG:k B eӠ D\ZJ,h@T+-O Uq 1ls8Mf cX&v +Ɗ@R5Eg(:KP.NDt.xL:}0H-JX/EAh T“J9H2Q ޏbGa"l0P%˱p8"8ȁxcW3L*ĭlr.+ quVk啷X.S( 8.RiJfMf =9ۃ;w& G[&Z~E͌ ƪ`!(g*+(.$%!F@eeNivp*!e0+HZz'VWX2>L+T8GR֠(pK?G& +Z[Ă+Hz1ʀY0Se [pqC$6OĥRP(JD= U"\!:˂aI^&4T%n\Z@ |'VoN8؆\ [sv[ΎEoAA I (bzIQNv9x +%Ain[_ j Nv3V~d$yQ!ahlBD@_ۜ^kQ½2 opt1_|8W:8|R7.oDsB3i p~$x׮s6"4 [0ނWAɁJ7{a&Wu$ox1C.D(H?Ij'I +9%20@ [GAprZCp9Z`({?]X|R,8:Ha8[&؏K0qYpA ^Ip5=@#u*SODq#//^L r<I%N*x /([ݡUS=72˫d FqnUw&7!x$d ).OiBȦ37&:}Zk2W5k5-sˬ#W(1Ql+Ƃ6Aq^L2!V9ۭ(.G1= 1 /j@9pfT &e-I&bR4 +eޝ.7-EMa8%Zk v8_ +̙$,/0=BFЮdofb1KqG?QD ^08VXd$ȫb 1# J @$\J4($p' T1@%hRBp4jwCD%}/n'qD,`f)2$\%5EӤ+v\4xBj)2[G!c3Zkv1rBT*bz0Y\!87IHi]k&3PrvK+,3_U]?!8fz@f`9mZmNOz!vSO(  Lʮr s,8 B)mv@B*g'gI({ sf Tx.X}NmrP I:U܊7@r<8NHM΀C*( gc<&8[?s0ZHQU%L( 4iB.Qz2@&zaƘmZjR T}5̚]MRag) Ȃ7PS纸hB+5W!wXJI}x`l!dFziͩmJ١NC(?>U97-9;"$oTEbf =W^*Vypnn;=֙pD &)9`VL/r9 f`[ʮ Y +s-o5;16 +Vu_+njm4pYZq@mkW2\{89 :hg&IH\CTG(fiY*X=)gƜ[o71!'=`9S4϶mJZ%͖U܋svi\e6O>t vlFn}"5\Zf@I~<b@,\]^ `rUMk5PZ~AcZ.q~t\\mo[vBEQLj)@lZ]uOD'5|0/MkS J:h-,xEWںxH ʧ +Tc >U +Y\6K0'VJqr_/)ٗ:!WurlvqG>TLQkKU9;7{/kĥVe[,w: +M, ʲ +6voHu57IrA(W-)WL PgIwpA7EO5Z+AaO Pi- +ثܚ[\qm,M5wv4juʹe13Kũvʝ=T+)m)-_sט +eß:UruPpJVb",Oޓw3p[fq-ŖUS/n s|4@/)b bg:V}+>hm>, +4E\eC\녵R1 ٬Nv6nW7uuW +|inGǣ7>vەU^jvoںK{n{l3௄Sh7XkK돊hafGVmǬn;u0:SsFi5ɥI ꥣ'n}8Q㝖6ˆT*Z~7+Ղ3(R[xTϟSzWyZIHUۄ21RXTX\\Q7-xDs7Vw;/Ƨ4w!UVo\}kλ/}b~r63qqxsZiZk~y)cQ.#zN}rcroq{ɣw+RZvz6\PMt+˒Ӭ\;W~ɯϠ,Fq{pNatwziw`NB3̦j&7.e4kQ\OgWځY;P[2v'A9MWtw4緯?_Ses89z}꛳{V3+쿩F^jnlTW^RZIkTWQuv}n۾\dcd ^kklvEr%#8t;isLטf30I7 +Sp\^o޾Se-;(\n_>zyz0kݗWAym$ӛP +'on'5~U*t۹|Mόvqo7C6WoP(4k( ":KoelܬL\<=sk`~yj՚_iDw;Ts3JiWn\etDɮSuקW^]}mszfiݭJ} mN￙/O3Iw~nDS2|?hobvo WԽ}ɷrݳK[W*ف^۵T{?~ݝ'7^߾HL6ֽY^s{>"t0 ˂ӂ3 8 Rkj\̢֝*|eVNm>ܸ8;|̹ޛ7Vޮnׯ=}l0jo:ZΝO^>j]n wvuh|p)͵w2-wY#x :럮yv]ɭ7\ݬNj[+ǯ~j_X9yX5ZkAkk'^d5͢Q+c7ӵd'RB-ENwZyt>}..?9[^[X^̭{>ʚ'׋+w&W;DH9ΨR=\;x8;:%C6Jp`%ߏQ]4ӻuP1:kM> /g++ݽNkϩ>O~ݩO#a Nj2=>yMYp[מ~uvS?yg>eV?̫|\>U\k>mm?˳^D]Y>dGqk~wxx[ܱWWj)gUҲJza vwQY-22 %97+ bVB{˭|J~tOyә\{?oW#s u:|ȵvi7S]K׶ڦW&WlΕWlq{ztrvvhu+_] ×Eg6!Yl +-/ʷvP7^nn=;9Y~O՗㫣ү}/Zop>'Aor711Bll$]߭Llj16ŚU1=5{ja]q:dx`g`' n7FZqRg`Jr}oG;iήQ>'kw@:w 㪏 +YZuު{ˣfmʙ\v~OlfؚbuʾWvIL-\v4J~ja{wF~{vҍ[ww^Zo6GC*F[ɭ;WkogW+XT'gW&o`{ٝo?~u ӴlKp:UwnxȉizqP'O˓t50iۂsIgz6X!یV*%d0i,<_2Uy{K%*C:?{Ǯ/gfyz~~0~_-p+(_XyxE0O͓A *uY&fy*پV} p  7 R9[dFa$ NdL{-(NN!Рlc壝y@Š[ٲ@ǀF1pW/647IҨ\K}JV^_nn#݀) +ԧ7Q!0֠vytRl ]DE֖aL̖hui.q_"BYR +J:gBt?q$=6Oʃ!_fQƢuL?-+^ki"H"6{V1cܘ 4A;{Ԯ\ujUY{Yk)OH5% 'v}0og[W+'vuTӭ+_]L +;ҊNu |ZkGtCoo_޺KZ9{S|&J305wuw@ y:Ģu$ZyM.̵fi%ˬJz/L 3SܮtUxBPt˫hD9 GRkGY/I[Jmɮ19)۳Kb=oR1*kѕ"*^+Xo߆NcU2zTvmkˣ3" euJpe^8m(~i er-: T{0"D N4kz%hRJ⚓zcQSJVӪ L UR$o!nޝWrٚ~'Hl7F +dvd[32kdw}ieeG֥|+ߙvμ.*;᷆JT*:5HCic>`7*;4^ +M̋jU6ls*8n(jbu蹊ۘHYj9Zh:[7{SܫoZݥcWή|^>` +3S0eiYzzrݜvpstY5^qM-].Շy묄i!%vuk쀕 +ǙHM` +xU$Ikew2m3?݃ۯf{[ +eT. PٕJm6#HS[յ!tnžMlGveܜ57f[Ro?xJ~RrfJPb(عqgZmp88F<\rO(QyxعZ]r2y{4?{zɣ7?/~ |{o_oO̷F;w0 ̐J{__;KO|c|׿;=ٝ۳C=wczuOANnܽ7>_~?_~[~;?8*vX-Gpf Jg*hulkOomxx[_w~7?|/.w !4fE\iouW$$TkLwo 6fNco|+ҿ'?ǿsnc)r;Vf͒ZzӍGwҗ?eqozg{'y(Hqf)!ҎVh<ܸ'o?OwϾ/w[>o~SLp]v8cǕV<*ٽ7N=>{?ww_<E7N2;VZ*0RN+[ko?׾_'O[wAq%ܲ˫kW=z~g˿g?~?wwz6)9/WVћn߼}_ޏ~~Og[僓+~[?'???dv9”u\W6N߂ύz3_/ʯۿ???_ask{Cy1:+w]S^oN9S9|nݜs 4QA0!`DE3~ n_ [Oys.WkBT}S/~Ep!?'|?%H~?^g܋:rAC@J|evbfs{/>?ޏ~go?ǟϼRLVp)ٞ6" F+|}b[:q/|wO~)?_}O>ch&%Zϭ:ycWܹܳ~ӟ>q?7~[R[Oue 2Js杧'y׾_|λޏ~/o77Y?SF.+DVffW׷w6o_{?o>g'~O}?sFsHܜ_?~ɳoݾwG >~?o>wƓ"F`SZ03ql|/>?ǿ>7׿XaVBe= +&SKӳK'ξ_7o_G'??|\Cxf. qrP[e{/}oo`~~c]q?oqj\6 zWv?w_跟~_>|/\;1JV㗽ˍ.:wS}?/~|7yՍzmzV떫|Н_[\ySܸy¥3JV$B(;1#p)Lҕfvvv6v'OٹOݾzO^֙[2bY^kIK^? XؕtYm.]x㷟}o?o_g/_עI9YӏIBSڳZ{zbb}i_}?|7~_{gy~m|NW~eB^Lu_܄G*VHwV{띷z{[o/~xSvV\P݃\l0H`u#t:zܩW_y;/psF91&jwGãШ%aj,F٩م\7ݹ7{ַƫ7ܿ{ FX/ +ԠX<hzss֝ϿnݼK_O3_҃'^y g6'ner_GNdI۷n|}/?oY_*U e)NSIA1l>0EHڒOqDK9:f0"c@DP&2l +z8QNs2%ub syi2abӍ0&4FQTPXMMn?;I8< y W@rAC;4&nQBV XP7¦NtRY +xlժAIE֏ &;:4px(R&%j1Mphme*NKI2bR̰;p=`, AIISd.ܼqA%5Ri;>שg3 vd`>8*Ie-2d>.b%lӞufILS# 顇w$H +a0+ +P/giI~ixZS(θh*\]>7 @eP0?REMR9(^QR7̲m}lW8kT=l;<`n +p 9-ͬD-I1EyF)CLԃ(;4/ Z|c6F)fW Ĺy {R@:?1BFP &|0A2 038= L|Ć>2`ӀP60t¢Յ|Ӌ!yA͓|20zÚАP.&.4fFl7 `~~Gr7y%$DǏ6dVE٬C% >;MAؕRҨLԉ(nL|-ƦBXe0yVO9P"?zxt_BCbʤ1o&7#0F`84#2  6r[$ +q9:)'rI_vɡ!ȘłEGF$!R_uEeH =P!`R2nj"L'&ef~,xa8fjZÇm.':5,6b' ex(U:p2Ȩ +Fj Rri`a*q51>Zb -9Yg )AiEBʉ6R1dB B%I|粌ZevFk]`, Z9!}= |T"̟ʏAT9p[$Τ<5<x׼D,_x2:u_ [ݢZU +׵HUʜZP ;=LPƲY_J9{O/au8Ns8!;{_a 2r}jwReb3jiìmiJ-;r1@4ůLj_!4ɧQ*#gG4%0ȘUO`rNFL^:D +IQ/ b5ATcԑבQ/`I! bN ph۝HKJUNp`Tj"Lz}C?nbl.a.e;q+ pR||с1ߐ Wa.)".xd Tc&]>2 }GttaQlZa`(|3 ar\BHIݭLsb H8G< D',:GٳQqd/{NqCѯ=pVH h./?f'ǜ ά^#-A[6FN 0N/ ¼EtҨX})aD@J >cq}l4@yŧt83cAPL;yZ#pBt͝p{.P%kµhc+ދGgZk4HI xKzjtaA;0/H:tI7a:x7eVѺ|K>.D  $W@#+$HU?LPL܇a6R +zOKbZ33g}BfȁaՇ~°h-XCI#vGFN15!bU0==9z` x րkrZQ۰%cS@4txpx9ZPbc#fU:֖2Zm [!&JΩ-W 4dQ p6$LN"8qyx1,v~qb2l\r{OQI +9Npёr<(x-=VI^+8iR!R-mv{(9Ҩ "!̔Y^ 0!"6(u)ًWٕOJ‡j1Z3DdO/.aO+̜ +K@y1j1^^b*e09C@6X +Be仅u0l\Kz2[w_gPZOhf@`@rV }Ʊz(5=3ti⋩~.n\?mq5V݃ĉҵgb U0Uo|/|!B@c@$KիOl[>ĀKiB$jjjJ)zjHO׽D@&A@xQ=_ VҚE8Xm1@FU6%&bm̩;V X ?Sd!<԰%N=rn ldRˮI!ҳ|̋\,}ݭ/_3Nac+'R 1B(D.$ 4d<24b#P6FCCR@Cj9:V  H~@1D(NM#@bR6pҌ_)9Bk@J)NI1P\0W9RN2f@Hx4Q;P!5Behhdbugfwn=F=2QJq37ίRj$(Z`7b˫cfyGfzq6dVν8{Auff,"Q<{yLNFݬ {pzn?8;$E/g +n,E1>KiQD2f."kx| pT UhOHE &Bz@JqS9G+yN/j_x GGmߩJ5D#A3|/''I `ɪFI!Sl$@m>$-f0.BMBzĊd6',c~L*"F"uqZWbE)^0*كt}-UZ\ظ/B\OmlمpeEx M;7t1vAi33O u}dyhEd%5R }!ܐŝrf 'DaAtksae~V*Jazfiɱ ARNHpkn*rXgY-!d<~-:^->h cV&jy$%&*ݭ.iT >ă.Lc ցⲸH5jrAI,leG@jonl VSFH'#6R'Q.ᢽu6Xb2,m.b&9\-vpB +9AĎؐG vp*ca#ɥS' K/o]ba2҂ BE_?G ` (`VԲtG$>mN<{|WFmJfc>Bd]:پ9Iੁt_Aq#+W{joNl?o,kzil*_^#N06ɨBpts ^&3(nbk)@.HcWˋp)I \\h~﷗N3F+O̶6[ 'e3/E6n|p l5N1rYaC Sj @PGE=ɘHi5Z-6[ +@-'#@.QL<̖fG|%1m*1j#q6%#;-F%)ˆFw=} 8Jvru*`j(զȘTZ% z %Ty)+ū{jNS=[0Qe@璌61r~ 68f{ #C2P&&a. ,r6XZ1FqU.r!UmfW>t_bZa d/5SYھ^%2\VhkrB` +SL(5ͩE;V=lsyX N82l RqB@KS/duMDO6cQ9q^`1 !GȰ*/'y)ʫIJLS7_Y;vǍhdFK@bLÀ +eӌXbĢDIJ,-[<?P>@sNL`ixkqb@$.8cX}]N_67jSǺRxxqJ \5v(F@Z:}@Hui;W^*O\{IϠ5==?[Z!gfi9UhrÛ ~}c)4SG\m8,WP72}%dQ?D7~電7kbzRK;4X[:hf0!hI0Χ=l0!,PG\׊reI38.FdkcKlШca &g-m_;2:| +rb1j\uqnm;DiK|u5 P=a# ԜC9<-6l>,ehDZJ|,Tefz|wQX̵w(Xԯb.·iNKU<Q1p0xNa@=#{aERs4kte=FRɱᦒы+sw~3Z4gg">Df% 8F-N 6%Kf,LG5w7j3՛O_jcr>TX eSۏ6ɱN$j$zzAI1Ù%%=mfO>}Ai|}jfVEN BsR rm̅Ň>.@Aa!BHƃ֟\Dy%$40dAM3+b1VK:~3^H'hRx|PMk0lNu玃Ted@U9R")$H 38͗g%q HQVf2=ZeZn/;g>/g o}ܤ]A)^rZn/$X=L.|to.eUU=!i!;jEa +[X8սvAdHSyN3^]oէe:SFn8,}[L/8fn0 #͹HKVnGJӥ +&%Ds 6DOINjA +7Xb&r5>Z=6 !M0|69!T+m1Rs3AesO ;[L &pK,kT_y4y_Z x$,ؤՖvnQ߈F-OMjKg+g΄ +z&ӏ_{׊ +p[Tf_QtaQb1?h&0sC6zFzP;)'&Zhל|闢@6 "W*ݓTv倁:: dBwvy?3̖Jqv9lO-p ޞ˯% Gq6q( _Atc;ڼ,ؖOݗ.Li\T4g +SvN j&?j~=%wo:F:*@[#!YGH4\hm~)@u'HP6qqRNsz>rS :P:"G`}D؃I"l,&̠~`D)eKݕSM?kWN>X:Z:S? DQ$~+>̷a\`rvv,U;c=7:Hiy?:c.Q3s3g?s+xUnjbxs: Aԅ+V p1Ojƒ3\9aSR{9 %JS2&[S7&V.)ˏ=wZXJ,Qcs'$rgeDF/P'J?xvꋯ}{v|o뱙O/v/Z=nmKۯ?u FY0^X8-ը`0%" z_>ep%\a#~HWLa lGEmTwntonD|IM?񻄚'Rn0YJ"1 l_c6/ ?/\33ұ['_~zjjM8 `hVy+@ BfPţbZRl̯r|gҤhq.S_"GI7VKN(7k+bI6wO^:맂LOd!#9qSNTl^h"R`f8sڳc6%;=}KݾpZsiFMÔ/>^jW=/@iL=}|sasgjϡJ{ܾ1Ie:XKZK3; `3UC54]q%)`|S(B +ӤSdjs.>T` !u|w.lZ(Ag8 ڏX)-> sF.7CLB*~p:La6,~u++rۧ}p썗|m|zV2s O]yi|>ѩ1;~d98 +aCLf6Ji(酙PazS7^yl-fO;{F{p-^ 6Xŕ3ldJL.F e{7H;0f& "K#:}Tyf<}| D>NզbiLݸSO+KRAFM#3`W(4D3`!TweM/ +e2eZ I"Yr:q9R[ Бӏ?`l;]Mwq.?]lp7\ZtZw:5O r½Hkm]^Z&J_z;뻗<8M(F +)dssA )0hQ3jjS3n<]?䌪NwyRBV0N80}A +.ǨōYP+mF&Ct{'7qWRT̞ovޥ.<ny杉b).NX!ޅZbnDj,hv%dhcqgjs弜lm].6-9;O@0f.ݬ i/e=+v$s%ClR֪D&a֍!#B¥AƛPxV;w8uXfhiUMPRN4@zAEX *rFj1{bbc|"}8E SbFwDW^}q=; `L#t?J'H. Oa@J +C耫*0pR$Ŏ=I<:F:0ĸY7K+N3~z7Xu1Kc+ƶwd?#6)I'317?N| +#1apb0kowO/8{퉩S駧w\xa\kUUDX_RTb@M KPM ׍D|n|M0KZYৢR`3.DNW4ȂųvFoҠ 73|e +g)%#'yMp}KX=bc&e^:@ @xD`j6sarSmxAebFͧ@TS;W+S'mƬ@t6J|Fɍj zղ`,Ĕh7/^~6b&' LHD7$ bglĂua`:f~5*0s;~tGIyDa 8)?|r nܔ-;pU#MQF 3nl*h%k^TLX?F)9́fٌ\>zx;<$gJ㛘"y|SLuPA2 ء?R*Z"\SmR͏=)1$*wy@$ESR:?k =43k|ܓU9ӎTjZ`gf͗;WTGBF jk{Wjhn$=k$&~P5ck,DH2{'<\R>_~zµXtmvna|)?x񫛧:Hcu+JrՕ#du@8Qb>QZX,WxZSkbj/9|kgOy+W=cNP*A!+5j#_^D>tr_hTM,.pV Fhُ+,$D7\YNٹfJO̔{%qZrۋ:%`!f`зep7*-^6>6QBx!#6h~60RmB~+pJ^TrcAW`Q"$k};cKLHCUW9WuW9`")%Q9ز,K $۲G{9|s:--|"誷~Өo5aT7zg LyHIN(h9_ںpqK08SUf N,gѼIp!,PLNa{as!q@%g 6Ll; +[ C2hd 'k9,ai6LsrAԫ `*=Ea.m-3_]ڿWX'8 +\}`| +B vU Ø!5'ȂglwY)z{Hk^q|x%0+#)ikֹy + +.$RaG}bz|*5 ?nmt~GϿ~(B8EVk{ gӼ =hKد5>@|Eɬn]t7c +`#oEbh(D`L8*[!]]xhWƂcR\P(͚'$K2N,FOYU*jZܾ !:Yǡ;\DH}Fr f!E+f_쟱*jf^) NW:qXy&tF२!EO9kMrSJ!&PrI$x]FBAVOTqDRf4A3D1Af#lxpHZHFM2,Bǁs1nm ]a՜l,"~+QQBzWN8(%JZA:'$ :Baizk\n-1fEJpc ~PN(SCNNI)IxҐy=3TV +6.vu2*֟?3`hc4ʸh\@P'8DH" Ad9reūe4 jIv@JmKv(,lS$">_N}@O#ade/b8,(K%(  "1LIe CF)EFʃd0 䓳 cSSP4r &iNR +FJSv#IĹ@Ng)e?13`9AȒ %yψzl/9q* t@`v•;_Ʃx +hE9Za|.A,=}o~_8y*A3s/?ux;3(`#IdKlϯl +y8kAIA<'Z. 0B`f4oӢ!D<Es) TDIjI2\ay*;>TNR&$f„SCLk +@8ɔ |W S 3Bi#8/ J<(B[bOSMv 6j@Nn es|qh tQ4A$)eƢqϗ&(qv:N L {kO`33dLQռiZP4 Pљd`.j2!LOE|bcQFPʜ!^`M/]UͪD DW(JRǎ͞: +%1|й di,L,!<(h90+ eţ %AgQ3Ӊ@`-"QA4ް(R -Pi\5M:jsAAkYu4SonjQ45db/[5`,`LvfKi6t]GR$h(u+o5V )ƚ[Q +"q8$6Yȋ"i@k۪؆&2Ȣ2 CQR "IҀa)PT5!<"P*T_Iw ˯٭ЬB}mN8Eib!L"ͥ uSU1L$Q`i +xcdILV5bCO>q\jj'ǎdO2%?> +D0%W(-dw=M[Ҏpܮk#^z3ww,Ef67U +2nKŖVźzZ|ٗמ/{ϝٮ糜 ৈ4;`s|ro +'Ir^ۯ\;Syp+<< #0jP(Q:uq*x;_|xL߼_|7sW..b)3JFŝyhY-bTҧr{Kgxϟ^| JE"Dqyj6Ǒ\VKZf!Y9S"wъsoxS7^a^]/n-< "WA46^{yy,|ǯ~ӷ}V?֯?{޽IфLcG +rtF><}O޽ΗF}e߸_ɷK7o3++,s7rwt{/]ixn?͟~?~Z_ gy^ǠTf#.3Qno-[77G?^7R6S4jj8|}'~_~oՇ7;RZQDu +K<2&kȵEަQ}g~/?~݇_g?Wny!80_7Ub^>k}#S߿Gw~^ٻ:[TPE`A晡>c>k{}Oz?K?{у_7ώ0fBe<_It|oGggκ\}x}|7~GwZq>BHUW a?s?x峟|~?z?S|^TElSx$ꐿx3Wׯ__o7.>U-Ȃ rQqnc?~/q\W7~~p~r?~~G,}Vd`p+ftU^+=9=;o?[?z֋O +4a|VW *UVa۩˗\?_}{n6'AʚY4kf3 +_3ͺtmlϾrŸއ/O MqYj(R7Ű4eCvkm<~.tTXTiZ¥|(L_Mf!:fڵr4 *rFv#z(X I#3G FeB7OMRtqZu?ڜ#'gsq6rvY,1Z Z._C(F4mFcH+)!;2LdUe=ͩ{ D.5x)M38n& +#t:QҠDO*^SXs}í1$a ţ MGRzyipB Pɵ̢`Rln6*b"V~V\lVHΫ/r !-_KQޙG`/LPޚ^s_Ɍcr_1cSsDo*VKQBT +hFH#~r*zd4ƀ̺䂁D,"q u\nX*80Qh; %*"8.:m_h._j S(Rb!Ng &9 wϜkZupč0, Po0upENؔZ&4p֤VG,d +Ra.%PӬ֢*5% +50D&:Og0ă36P.8dy1;ɗi_QJa,9i8nT.q4Ce ӘXԲcw2! c4ĉ\,LdPK^Bi0{rJë/1J9’VXOn.8~Rw#L&H1ڂ"d% +p+`T*ڜG"DgPZɎ~MP^s/=4Xuyq .w4<\lu٧ YPZbvioGםV0p X;XykwNmi\Y 6KNS23مYX2\(%H_͎*"xII.ɯ$?ݥ#5E8$We%Jo!%oHd.ܡYrjNu;y#֜OP~LTDA0RoސqGzpD JQcv'%U:e +a DwGVyk.Pr8_cy_„DNWJ"LI`MCjP DPB endstream endobj 50 0 obj <>stream +zxcDt2(iAǴ ŸHR&%H/NɊmr\]@eԕ}ƨN_,qNϩa*N/lS*aZI*-f|z׊7_Op9@(a^ϠB]H/; NOTnWW {=lVڶYYõVn_m|Qi|i 5ND+g&yjxB( N?;i6v4Vo$WRЊdZj;vˏ6[@ w2ʅ +!$oUn]yfMmJqCqFki/gWJ Wo]j [c yєSy lzƨZh[lRI28Nqe9UϲJȦ˫W_Ω3PF m]/~}Rh +Afa~N{]9AtcS .#a>~2#N>Q6L@NFYX'[N?s]غ7Y.5`܄Z%ߤ͎춻W8gJnY.l1-VW ^5W:fD:F?^ȴqM[C}n3C0 }mR<魊-cU7afU%Q2˥kn`ns̤7ʋZelm) +dV-,<8[_)V]ޛַ/<>ؼzgAatM-2rKwV[ŕʭʵʍ$~uH)__~zPZ]x21`^Zޒ3 +iN'w&T *Oi=wcbI+mDR=X1ۼ[YL`}P 1E@\Zq8eN-Uudk]v ZqI gj㻙tp93Prcsp%Bof{2Lix1]9h^Zڕ֨Û]s|`Q~tQ*=:w%$^Zygޱ+iTNoz{*]|۽G:-7q{$vQqt`V=pQi=ϵޞߺ'RGAKr F{\߫g6q N-IwςIPm~frzp/qzS_{0RR mqa2!J/px[].S\.tqٛm% T&FVVα9V]*[zi-4wQY@rqhkϲFW>Y^¬1n;ã7޹$iX1B;ny}³[x5=HԴ{'п {})=tVe%smDH%>av3{3H6 nm sQ&@ ̚<2Rølut0ڿøS&/\Ud>gUbTL)&zW9on]wI@)fia +@9hRXz#X(@}ުG01Nk׬nqXs݉Y] +0nb4hfrzdiPvi8`7"崓R.%ڶ>3>+k֦^\ +"*-"XHd*7q'&ؽ0*NjlF{wC@$թ7W.j2Ii]Z8W]:Wc +-MdOitATQtM= (5c@ƥ\j{_]Mʬ*M931,s˽kKOAL&AogIʭ +}Rw8cR-97z?77׿8[{N#07٭ x_tHug\WkIއ*idPb +na])e$RCh\ww~V,m# @n~+:a3ܸM3lXY]9_~<:1q; +M핶TwrRvP-@){IuNm,i@XKʰP"SiohBAk5+#O)t{%)5?6_--],cT@9N7\8v8F ͇Q;Y `-bjRg NeA(&-3 * yPr][  +N 0$z Z gt=)#Ug]eim77o:* :c>gk/A`٨hF Hk&Fp„]$@Dr6 Q~cXy.gd֨0v7qO|iy3727L6N@ mU~\g<`\23Tb2uZex."y7ܧ~09y4Ea`r %8j ɎW$b!n88+,&4a E$yuF7@'PL}%k[5Dip5 b@JQŅ ZyM).+eg_ʏ#)V ΐPIrrSߌ3T'H p&aJ:<ș7Y&XM +Xϥ@HL.*x(y=%@3SA:I@ , .%( W8P` +7Fk!I4vy}3?gJ!<:Xb. `@L2{KP B2 >1jJK|{ns^=dI h-+RI{ViCW)eR_❾QX3r3Yw9؄,w{&]N.WF +K0W,pEyJ,;QXک4_b)l."jݬlB5l'`O%DJS.ž\Jg!ʠ%>KRzT"T6i`fy8`͆q71.,n5 Ȝ՟Qgr{<%@N16XD%(VXat;!XՒS]etiiac\ZsXJٓ#v.WLJvn_{8g8,aBE(3FqrpItDYRE(U@}m1;hI%4DͭWsC7aEWA>͍QR<@RvǘXM) ¦^:Z+Jm/)=t=1l#҄1;EY +&ί"\>@\bs6*8Oͦp޷kū{xЍTJdF@Hg#-H g>sdo`|IARO}P0d0b$(`` xz&@KrN sqZTlHRdp@>C)G *cQ!MRoTA;+l&hYPhW:Ƶ7W1drD*_ܿշKK@G<= ݺa:ynIN֪VLDej~*y8(uިlF2K߁FF>mb|s5?ldUs6)kvɇC-k ӠdNIL9+2)6OlhhgT +n`b!gQ.S]y>"T Q4#ts)^-42`s a.BJjQ[H#&nFk/AxhÄ́X!6Iu, :3J^.__ NpR M*sQT+;L`T"Je q:?y*NHqHٓ6U!bSBgЃsHi =;<V9MQt}fť(b8!AiB+ IQdrଂR2!Y-@`˔zC)87IZ*KQt 4%d>[;zy$٨"J$H/+ϵ701dk;r:G3LL-mQJ_*,^.ZdR}- #&2 {6w$~oi_߈ d[ YD!&#q=ݭ SZ$&!5RlLeo.1΀2)@b QRG0_HPn'{1ѣx׫ fI&*X4e1{.¥HUPM}>SZ4[ + fA6xSb/b_ЊMvfgcJs8H To>c2=y^g`LEBJE N+ll<+eWI  ;GS!HIbDWa?xSQY>0I%Ι/]* %q3qf,na7FMGNk`\$5B(vPu  z `R2zimgW$`ZھvHra<#}@z O1S^ ;6Ä <5nF(0ELF3ة :B{'gc8Vczs)FM6Q+{ZG1F2.g>ZzVy5NǃD(eBl  1Ҧ[1JbzDAPzɜ_%9D~ug2';A⸇pL$nّs\E\f2Y\hɗUo +&p3A^~q6[YsJveһIt| lrK3"!tV0{ -s5uOcSd@]_ntS +_8?gZYąV.]9q +“SRv9M2L;qx)9u,3"gYM-8aI;f3K 4{ {rvPA`)W2ivf u&ƣ 0sELk!r=d@n !0hXCLl|΁5l*E J0`-Z;sI *-Yy\R +!H17ePڞ(LR<>4i2dkξYdB 'S.ҁ`698[3+ax6B F%pԤ6A  *IqJ)Q:9{m^J98Pu[Jq+IBt^~@L ,RO~5"q*'dC˥ (X *¹J9 "ʖpE$]s-HNI1lDғ;&c"'gC,%dZf +.6"  Υigh;EQHJ jx!y=(FF1 (sZmz }@\RX "9 |z,=Pэ633scIqW\){A)CRBO٥H E75hP}̗z.lYMHFQ=ݛ|F j4˖"`1,Kp6BТ +b \1܍߽SJ-Vuw +dPy[K{!C #e- >B25¶h)ƏUlc\#;ݯs2f@͞ )!X7Biyϰo`/f4@O(?+;}Ɗ`Cdv ngc=cMPeFk#9z'3b\rÓOp\VgP)TLA5(f^q嵯@Vdayx09G/+ĕϲTZzZK%8LFw%'ݽB\INI2t-o |Wv 3AvRp5_^AKTw _x1XYM Z6' &PMoXּ_3W c B 3J@ He Eg*Vm$S~tهAzY \l|E5FKz++9v>J֨ڴS#=VMd^UhlR΅u㣨8^6x$c=;]]~:2 Vj0Bڮ6%N|O_x5"E. @^tAf֐BP*m+ޔbd,xm%kRqp!")Nϊ⎲ɭ9C+LkFnr âDR1!dc vhH +JadUpiE+p"~k ZJhnou:#D"l&VKM8-XhWaZ a$)K+}#~jOљ]k JS\EA.2C2-EOXlgPJ: ^/_LtKwJ|(S[ 7,Mo2cܕLg'KZDJ/_~}khJב:P($isIz گ:̪^~6!c^2o?L@ > + 3/!Z|O'\ ceG`7+ZrrڰT7}#=E!Bmb!l@Vt_׺3=ZtVf6oLlLBNUOtDEx хM be^ҮKh&b^@ ؙԋf׳Þƒ=r#c30xݱ -``eƨEy'@4C^D˒jC*V;nX}^ŕ혲d{sm#Y%4053p H:itqEgyg +IM&wb_WJfU KcHbßFOP]Y~МL-Fݥ Fm љ%{fV0&arR_Gzr/?zj*+$xZ&Nrjtlxt|Ґ=~!fy&RJ@h`v|WV qX-ZRnq뵟b] So[8΍dÙ}Vzk9f4'lV _7j5ӭ ,EC3 n:/:+ Z4)+^b|b||FT{ϲpJH:N+hj-r7r[޲5 3ۻbQ jyhnwt2}0> +Ɋ=6ǙSpۯ &A`-%c[.7*/OǷzWZr ͙C1rdqW#Nh1엜`0ՖV)aPA0ZGVb(> +7Z~-yU!lTs +cko>wߎ~ _ف$su3xxx3: +a$RCcz/ "3St2@ӺaOH 愐4%1҂OK*Y/N$hB"Vkc=zIYYB{zpHsFfK6R`mB sNY*6dNZTVoW%[2y7́``0ݽ lg?|Ƈ3E"W?6O ƪ-Ÿf;Nj{UIiX~B Z VNm4zp@рJ8_rippvN jF>؟}-7:1AEJ~S2U1Nu$٨T~Be3Y%vqu!Hh%' +N4IE1z MبV)_V~T^ppϥ$HqWn Ik=[~~!u7 +}cz|CF:BBycgF!'}a0,5bnYmGߝ _qN#tw윋w&|Jleg"[ ;q;kў4YBȺ7믿. gf댮xQ,}gwVwosы__~f~Dj&<+;&ZW]CUZr˭0i}%璿%:)WJ|g"$Vv&'MoOEjKo+vP4;Vj@UNX &ה֮b )-΃گkF[ &FT=R::dNli{._|+mҚe# +dOgvoE#Fisj/^U}]_3閲9!5֫3b w_mYgV) -b-:ޅ0RPY^xM#d(> %ĈRR(8k[K +>ռ!f<&7RUxvoWp5)W/9J/ˍ..$Ł;80&_Otdc} 7M6>geٛ_Lo~mdg\'w7r̹np)+*XɏO;g k=xfxzewnAZqzAc>-Ft۾rs3ZHs6D\QB,h=|Zl`]!v#'=$yS|t?69hAXKٞA.{k+;NY:a!e^z8^֘ GybL?iYW1`y_0ҳȄ*A&ܙ7Vs5CcX}x𹧢5m`:ZSt)J lKj}}oؠr'3Ã`KI꤆#F -Q1XHAk32K;;6x|}8{NV9kN)]DDomW?@Zvso<lxg|y{[#OϿ6cJ,(ho +JW +phd:>zEsJKڜG/Ʌ5{WN\ +Vگ~px9w#&eă#}~ywϿpzwHZj +֎\'=w3_BlF+= /$ 䅠v-KMComҿ,l#G +VrіPu."v o:o'))u 1ЖN$#J*]~GBB(zqvx{'zkJhVޫֽ/15sL?o'TE>{K))ӓ/R +pd$[]"u!)-6*z=N׷?,;+Ҝ H1u'F|qȔV"ޙ>!"ŨltN6גw׻1%Vv(yxӷ۾U:8j|78$k$-meguąCpٙiK&qGwJ_M35OwgVL5HAb]#\5F3o}o>!}~]GG~ +͝7(D&zyp@mȌ9L1WyKZ2(ΐ +0doBH蝖܆GQ 2 YJ6W@џrVx78~heH%P4owJ)/@ބ%ŒN_.90ApA:cQJ^CFR#E5R',s)\WHG0|&Y# V ]U \ޙ6|u_ׂ"\̎BV| J/tweS@89~0ݤW׹{M`z(n/W{P/FR9%%Ֆ%C]Piw毱.M6اAH;=ZARڸ&k,&m|S^_@8ţû>-0Q 㴾B`C=:|;j_oFczjۼ 'Gsa%4[~WS4|k;OXcK"wx}udJN aEEu瓓b#ԓ7q8c*K+;UyurgÓ_GԐ@e dνή)w*lS/O?9 z|^}Vެ.%M1-%BE:U6H[btҙ^ߢ2) I_oNѲU{d +VV1/n>UexLߕW~j\hsΩ%xrPqu:g@F*؃ҹ>eL``X$[Rrd=AmI63P}>&7TT[V(0RaZ~NIq68%MZ&ѝ|֘؎W7]~v:IO*'g;̛XΛSr'?x2\펞?O?iYMWᬑOb|s<'ٓق|rT[^s9o _~N_ߧ?Tً.w_? + >#]7fxy/ܓ0&|ӛ_ >R}w'{XFBRB4r,H&jYdȪCYF*!ʢMwzR9wH9ŻZeuRZNrPr֠0';}=^řN mZ7zVpB׼>RC0J"/%k*]V6Q~(yN๙.kr0hJ u0~,?kZAYػNɝ?,67 o6D@F ƾ/=$rJm59Ά-gN[RVc&Prf;+ސbH/ML=!\+g?/w&U)$+̉`/{Vc3rYCP{3)#1"θ1FPRVWv^ࢽjݤlAd``]^NjWjE!N`m*[)5B#+%k&S^hW\Vѡ=3 e'+Κ41v6T_x!AqSJ+e]`[٢ R_hщnDo[~;yѱۧӻ63[zG07_3:ֱҳt%1LK %ńbL_Kïzm~tm[#+;I&ۏ?6Fr~/~7)mZ|he'D+Fa& q BH)k(`0ۼ;>1a4Er!i(KI Q֎5Bb<!i>WlpSiڤ.7E*ޖ3:6욬HB]X2V1Pn~jgMic\sN|dZhGg?զ,Hlg.jݲHJQ&`HQSJw ֡)< ;~a~[\tD̬y~sN6Ĵ1 @ `\^"tk{ۏv{-=^6`1ЀM:1{@~Vzz}OH)፾̴5F5AzI^q̨m5 Xp9NqvOILODO3a1*cՁF(pC$>b;!vwm'֞liLJ3!ݹQпZvC)Y$/V;GM1j׵lu<|SV> V9:Ys6`-16X4VX(}YGk#;Atj'>-!#XjC%`iU[#>SN-&YUS6U\6FM6j-z dou+&xO*9-&6Iuگ4gI!WI> -BJ9 &l0G1Q ɏq0~zWV3' .DFP#pYI @Ї޷BϘ^My{N~4ʫlO@i5Fv'*dNsP@Oq.a9?bp?/@[mZ/`%Ay:~U*@Ĝ'jpbHZqxpvv+HNho~;~hJdOuV_D979rAMA1z9)fX d1'Ă3Z*8М4ߧN& +F;ё-ZZtIVvm窿/WS8,۠j k8hB'X^Ft7) T ve6A-Z̰U8h)S` %#>Xe}B\8h3Nr4h>Fk-ֲH.>ACyW2@lG8`S&+8P&x3ZpI94'Wai>N6Isk6^:4pIaZ1&/[+t,bO %g襛e>+RAvVf|ͿFбe1}"/pdrݯXFK=;ѳSX-j:A D1_:ԗmhPqɏ?mmBl V|!⣸w=X|dG. 8e`20s@ǟ}xOY3C(v +@m|_mNpjhHAHǢ5B)NMi6~S' ^0JTt ^ }L +a7:D V0ߞޚՖE#BĬJ@M(pٴ P#H 젮W[&#kB,,hkyC%k`@% { +$L5B\ڤ<I: 4pX|F?1Sp8`M0*MAQg?/Deq +[WN~#};n{֙ڌxqy_"gstkYjEI{OW?V.GmK@~y4"ӣdkFbF)߽F x l;)SZf4  g㓟xs/_J.ղ`a %)MH.xkP% `LBuW UY`\f`{aF 0MBH:,4~x^kSiipl`_ Tdxm%c!Ar!3l 8h1!EeJk}#Rq%RL Tn^%ZeE1V. Q@BF́fM^{MƭB'IegRֽS:oȈJM&X&-֩P&$ ,=Xeo0q//qX+=9gwodsg%^A9F_k1|af`ojGZR kOnh}&833;v7f~'끇JnߪnwlE0%-!n)J0J3=)igAM@m&G1zYAx 픛0,w*IGjY84Zzé]A!ni!(S#7^VJa`4)SF.K.[[3㯔`W!EX) +`Fo26Gw eTNnO^K7U'au$sⷯg9,EI )\*l?:go?`||˕-J΂W{RU[64+휼hwbdُWPC?^ok^ >OAXHdfϪt)5)'_mZ.p;4B@by-w5\ +ٯv(HGNrJI*<6H /70/I1,`w"zσ#YΔ23"`x-Z=8dB$#^(pChUVV/]oJ6H}.wɦK7^qXsz~MV/!נZtU2  +&S +hCcދ;9%a~J +%]̒n!5lx/ZNrZ1޼o)Sڲ97rlLޕA:!0X}LOj[;3vCPJk`)p(%rv7?Lѳ*+I;AӀUT;;Fˁ9,8] 95)%Q{ |JmNt-6'/>yrfJ[2+ - dw:VLb)Aȱbijqא+FM{d#'XNi|γE[ jy uBoPzy͆`47X7 +U_-(@|0:C7"2yuJ-SOj!8P.T2+˨}䳚4Ky8q Rm\p&f,X5d.%} +6L6?W9okJ\&XOlPO +HڢWNlG=\xsb.Z~׬3;pqPeB]fJpLSo_UHɻX/J0o-6nqYK`胆 d"cWZ~&ŽU# RI1%CTj˸{>`PH(+ ڔ Å]+(pPRQ;!ȗWqdjv\YU]r ++ &e"Z: d\Q)XhiGJHXn\HJ쀬7TNHh>РtU^Y޳~iJCţ=l!`TJwb֥$,]'u<BrCtlh1' LBLд XQpM|{sEh9cO gdkRÐ-NDmsB 5odxS ~ްְBZ3AzmvoYsH_3tMpZ̿v1M;RN- ;k ʙׇҭ :ݽ`z(lפgAt5oqM4/R5:z[s@QxAAf7tZ-t{)>1#5ª66d\H|^RmjO'OjC'O Y^4m'uO+?`dfmQ rw&u2E{^v _%&e 6) +w?-@*/FJ ) 1R,w2JAo,>8s;ؚJʌ`&"=X9NONh©E)FHdZpo.=@ +ԎnD{TQ6kgxe!o` y knH94ũ҇5z +oHFJO3PAK!>a}! @hoO:4 DH^ +T*ڃoPO8bŚURlY=ןUFKvH ԆG!4+J# *X51A{?|Fː)>RjP:޳)eZe`[?y( 8ɔHXhںGH&.Tfa (^`Ig{$(/+Þ@CJpL >d 6Uj5N.ltU,M-=;0?kAMtQa?Zۧj^=B-}ؐ.Cՙ9ٱ(E?KO>j>Y?ڍʑՐ.#trZrIֈrڑS|,ax;wj@)PU'^W #OJ4rśk~bM+:jc#;3;Y*I R^Я~J%-)&Vq %NddW[ޚ*L'V<+*WY0Z{yc=1RiYv5_^7oIOZAbTGmoNpY5n08)`LxuuDk6aИ OXBl!uok܆" @@_Q޿W-.ow9̎64*~]S|F ]RzyIOI&9^E(r{ X m:-ZH< a}[2{WO>ր 㔞d!?T Zz +ă}>u*)8G?)U &yl k3(䍉hNBFq? -o0Rnj1n9Y^儢1%fkoOx +,J0 CM!D&9jH(tXiȽA $S$Adՠm JHI>AX*܈ה +F׎fPqR.!kJCq h)LU/F|A`|%\5Qw_)o/(i2bT!:Sao->ѣNش9y@^S 5Nޅ,%o*8}8g[8U5`Sp&-/bA-h)ќUL=ż b)-)o"0u@A~!rR!#ßݓtvϼ߿\Wl.Q; t.Fk9=W- i_%?Qa2}x +;=7'fqdk-9ݣѻWvʷ~Y^}o;Ld] " *篕DN6#ZmL !\ sXCRm9^Tbh_xW`s]vq`|T +U4z߸[{%3j!y lu||L&.!vşCLq֠x /oӢPъ3M ?CNf|$˗n4]{gz~k l,.»O^vV&pe^Zf4 @란ɝɮ".6ވ^#l_Zy{z{7.Xt&S|cv|;8f~/y6ؽ/W +FڧF~Ss+Y Woys3]xpM<ͮ){{w۫7ݓodxpɻk _ᗛ}o_}gARN[E`iWtvqw|vFkFiۂ: ǯO㋟{òE?&돳_~/>1=g/m~Ng?ңi4 G77?w/?́wuxlǷ +?v%F4 ~>Us@NY9TR)Zsݓs `r2lcL0׻o_}OJU {mJ!% n fh6z\lB/̧T(a .KPˀX/TZ3;,0QB-șpm=\[K & ΀)U0HJvbs+H B'h m˙),Xpi$XyE%@T .=/ f>MfT/-No^}Esݭ +:ByP@&O%wÕI/%X8źFu-?y4;y$ޚ?|3?̈́kn2 VLhg7X^ipAF0DksTWkkW}7P^~bJކ'& +a RpShqFHM8H!\/MttFlyl*Zێ5f&'Z<8ULytcl}tv`ubkFmݏhٸ7jtPqdmR{tlݘ)Mh]X?;uuHgT| nBǀ45Vp@ )CLvx4}dJV1%GE2TJv1<"krNB,-jXm5\8xr*WW2Lg7 +HY8X8BUHuP Ǯ<t,? ^JұY>"A|*bj~OtDN f'50dž>~V07*-`J)5bf +9dõ%> puYNu > (2sv%3u"\_ײ3RwcTu}~Hq֍^̴Vk '˧ h}L `O>9_:ʦZXDe>\D+$,nrP3@0j>Z fe.5()7{h:،F7$c08Ry5?-ڭƓϽtb]]]-_w|cks%GO}/&'vvǧ>WuM0FskbJ,z '19OKltTr~dos'ϭ-FANnF Pν7(;d ZJExRK/!qF|mh޾|s#~><o W֪K 2@E'a6ŧt܋PP[W's}֏q9t Cdd0 + @* G fRrKgw. T1Bm))^\;~5=Y)gjvzwcaO\ YH1f8w^v*O? 7 P\@JuؽW{N4qPup!' Q<Ȇ)j oEq8nmJc Fkڶ^Z,FdmiwvFja!3y2䒓\u='=jN +-r1Pӹ#RoK?{Z߸ټtu*8r։O|z|i)ěa2\?p)XLE*K֖Us}bbj|$fY`@r+~DjFi!mg~` +z `"E!:IEd"uTcU V=Luy訝1 %J+gn<%1.?J&> U}4=^\^NG@ϟ.-^F@\3b\ +s zu3i,9|tL^9@kJ)=l1}L b 5;mr˳GStU%whz;DѱPbFz$D#WWQ1x SVcTΑ[| jdB`JO7!^]lOD0msR9`&,RJ(/妎Mm=v5^ +Y5yfa{gb`biD +tWFmKwO1ir_C@FUh^\ f#̧+ +yOt *p+=zGH+L: [ շ@Q$G:V· :qÝCCD,&f|n vXfۆW{-(XÃ5bbFL. \`I5*X3(?&IFQJ5d;T^N+uygP%*X#]Psjd#,e@rJ d{+ABUܸldW?D7ÅH#M|RN񶔝"&CӳLw,?y$X#eZ{Q;4$h;MvR|nxa*r.&l^&kg{6T G (%R}"TeuGfkr(//{;JfƎhJtM=2Rv.\ڦ6wIOIJIZqLϋ3n +z_5o,NSS3|ŇQ4G*iGRPs!lNRTR7FםJ-SZE'O/^=ςbt" r0W}hBu_/ w :`:*TI5bp:w}#6 2P@{`b2Ҕ= PlÅ[c0L| rOƴLmPr +l(23Ύo `@LXMU&fOE*klDZ!Vs*0\􉹝{nZ&[tFtQ2_AWm4/&{5p7rN̓΀#RV.΄KBf? 3M1;YVo2өچt!x-V\˜MpmP%ubĉ̩bh s##rzz MNNcRn؆z "P9 jHf8%Ԑ50LBa~0I0 f!Z<+ \XM/LE[Sdabqr̡S]:YFl kl&\5VMKѪ3<-ы6NFCԤgKby†r(% =5^598g\!R^6JkH/kBNZC +sKΜO6\X'?C1Kp(7RZ8R2"i=Ŋx0덙A![soX"Vo94rzHn&@ogS,XqIqe츈Dk#v|H:B0Q~ܘ @\>tqr봘ybИw31ȈɁJyBb=Zͯ{^0&' +R ZKrV*Rh/ɚ깹HqIMNY9v~&H& x.UJJ4q1>bJ(oB1\ p&cw^e*7>{߻~񵟿?/Ʌ[O[ZuP&;dzs~~y\{!:nӌTn?i]KV|3[k: 'o=/~?| KH6GY @ǁu$ Pj"^{B{~?ɥo=vC7y\w;Kclر>".SbkqiLov~#nzO_şooNEƦUP/1Rc$E|4=sҽgn^㭣Wj3m@Ccw:6hN/.>rˏ=W_{}ٗ>׿?O{.t!a@UUMz}g/|o=[O>қo'_{^}osde2OF.L/:s /}׿z~_oIy>ST-()tڞ_sWo=?x''g8Ox½+*E Az}ȥ}[=>쯟W+%e}Jçn~x^ }?ɟ?C߽ /&^x1baB,t6_=n?~_߃_ӯ˿<ώ;o\ᅭ.̋ϕSۇ^{oW~;~?/Ͽgz_OTp=-D\m]l7cϿ}Wۿ/}?[=*FJg=r 7n\{_?~/+W7Ŗl1dY5byks=<>ͻ/~w_~/֙L/uVx-R^ЈeSӫ˫[/_|go_ί?싯o?/|֯sC̬j&2jR#RcrvбGNvƫG >}񓏿>ɧ[ܗo#IS(pXTJ|'>_ѧ|~ы/^:vIV(.TR`6>09{cO~/?7OwϿ#^A9 +#f9`[6z<Ͻ{~?.8gׯ??|׹+&#>BE=F +Xena}Ư{w~O[p?'{eJ+aOt{90Kѓo k {>|s? VjS4s|g?413pڙqg5$I?Վ8 +F0OvZژ;uֹ~ o9{=E3S@{>pF݃W6SF!p[\??~>ӳ?yĹYHJ>Dc"'Fvv=}ؚW[c;>~'~ŗ_+㧟zlTgXc`*FQ;5ʼn=+T7˯W_~o'/<{у[˭xRcLqU#T2<|}GO?/}ч_/?;| OV%e TE($zGM~#zg&&;~˗}/<ۿ|^x~_xͣLe4gvÀL5r=E"5R;n|'wO=_s#>}bc}u|u%1V#Ty՗_yS{ч_xg+7VW6:*&;!T?L8'hUm`A] 0={, +<)Fԣy5C|dDI-cG1pBٍ3Y>?@8."+3d*dsHd- ÄD ZXSX<ʔkS EǛJ>@!Dw=x!TB$x8iKh݌&")>2I?Ym7=c&׈{ A +D_rn95jbq3>Ӯe%??:44[[9LրI 'â x"KXA!W"LnӒ'bIM`wCVtC +saoC4YVL c|.]Q"ar7Tj)9ʇ;&>_QAQX4LAfP#XIJZNKpzh?*qZy}aD~ f2ũ#wa hKOb ++`p$#Y Y\"nonvO^pBSDd58RK8Q?EFL;G=0K΀sWmZ|^1^ x{NwmT>: e JQ= 9߲ע|` 3? y蠣$aJr z`67 !A^Pba'F0,Aq_4? 0IVoFghķ9dv [\fQh`vd7V`v+z;  \20id ־QQK"|Aq1e2<.I&TùF 1viq۽Ia} ?D K1fAֹQV +\60 4н̗dL +>6d2lz aN$`sƭVԏ@ޱ״gb^=!#ܹoН~P/!NxAz=> 6`|!lχk&@s^ -}cȨC"}/@0ِ}CN!2&}1>8kF=%>fŇ\#c> K+cq* A=ltpj`-@II2PIG6D9!={m:2)ZZ .Ò4Xqx{f\h<_ɂm(Qod+uVbq߰׃ȌCv'} hוD? 1!N(LiBގ6dFq`n@Dԋh)φ|4E?e6$>ލDuz8:x!=Onn/cle2\&v) b ;@,|qR.܇X43 Pǂ` ID(WG䘃1C Bv*{.D3D ڜÍ(11; 0c37nC&H'Rťv' e'=lAI;ݳ5>ԡĊQI>#yqT?bq/i͍xrb`XJ%] +>5Nςg*bҠׁ 8.E:sV88p&a)fUkmG6ƌDG\<J0#(7Ģù1&)hNx qBV}fv [ljG@1:F˯"t\I)UR)#@{zi x V`K3J掽a@4dx췹RH|#8 +eÕe2Ә\d4نMtLRbhN80-]0tf lcC@όxTV"92QXo@3YIs!J sRR&D&cA|L + Z I:U3ޱĩ`q ta"Ba +vZN}#n@:U5=/L<p-hof&&Fzu (oW^yw i@+Pf)Z¥<"Rb3Z@{Z̹Sdh Z-͆Y{ҹO|4!6`91o4u!`^GN=do(9Rf./{0 \ \gCU07w kU /~8P7!AU59gDd{4X7lX],DHEL(PS+>* Oqw&62bx+0&l?,%$7AÙRu19)BNVa߀gnjqr4;si_"\ѩ$.`lyq!štVfC 3ǥpnd2v% T "ܠLf8Pʍn,7uAU<iqz"."@Fm>aԂ SJzB/- \*YBSzMHBg5wbнi3R=aFS9S VH&frJ~=!J.J ^4"I:mJ7\'] K Lm0{fgͧci RY wyI1Çk>]_8}dkOkLr-\x(ZrQF(` &f-En<+t@ڠ_4PKle.j&d( "Z~Trf'D ' aeĊx`y)1g6MDf|B)2D.(H0˳Z@.9}'vl[1dFM~`%lAC6Bals9GVf§I)-nHN1)cԷnGnjtG +p3R Fl8fc5;(J`iPy|b̭gDkEhlFKV=jy%29zr9]nOtac Î1/,)onhɮDs~&^)7¥"8..5wx`a{@J"lV + tu)?is<R8mI~s"OƤ +5Ɂ&q(snf{{NB~ϐ;(E;77Un'>ʅ܂'9}4bu^Hq`QTA(~GMO%k5(nv@.zGC"'LaՌ#/n74 BFQnZG +IL)ǫa9* +ܼRb57f1/(nXC%"L U@%TM֏}ýx**+Z\;ع_K>&6 q&-, 0^,BB6bB nsߘb|XXG'H k,D汛|`y!.,itlDDZ], xf#G<a6:ze:w荧+1d\xa0^ёYe#}.39)A٨;xȵY[dAl&' x(#EG  $+~;C# R~M- ++cpH6phߠ[Πİ{E?{yONl0 V 74itJڨC| +bӔƃ-@0XN_h2A*"eq1KFqjψ} +Z Jy 3q4Z_O909_o,_MϝΟa&DE$/ pE:؎U7I H!gX3)JD,&E$#% ԑDcr9wx;wS\oǣ&/(!ؐ &gpۤ\N1Ϡ%53[=j pa΄ک\{0{ +3].d>2dXd< ;,4bGF0t֥^:x9:p)…AxX3FRr4xx3W۽=U@\@=7%gQKTc/2;y!fq3&'a+oZ䁥;aW"5l#M ҁ1!{X{ڽP~#"2NF>Dǜ8%HO+Rr\̳e ͂=n`dHA L.CD0)O|L4 +RALoQ! 7 DᦀsgBNu6Kƀلm^!P[0Lʹrw8FxiqeC8sAF.>y>Mj9PGn**K~"uװw)࣒|mfm@l/v_>:f*Hxyrb+F~_,\9)zCf ʆ(XX"$;@HX)^L>H{`p@׳2!71;p1 rpi9?QP}m^⡥ǧ?XY<w `8pQDL:i{ BkEc}v70ƾ1͘ݜ &Qj}4fEڋ0#ry(,|8*pFu<~nt=UÅ(1KJ9F-r O6qvCNU˧ D9RRTP07,&;b˅@ UaVE8 tZNSrV7ؤ,AGx!A|a ^X' ƥFPelLJH-(Jтk~nz6"U[I'ks0S=I-53.oƔ{P49I +J#tJObl>3fc%%-.\bz 'ATs}vg~Z(Raiюya^ 8 *gK΀H+Y`|0. iit[@jMfA BcJ *.(:;\+{ٌ :֪rb&_Aȉ9)>*,-/{3w( sd0}#@} @!RWFcsnHuA*H`P>|^^ >vbڻ^O{ 8|̆g`%FH8R/.7}r$"%۝X8+u Fk8b fx% D&D?N{ ).MEW!@0`U4ΉPL`B`rJ0.cuX#[Yb<&D+UPJCT+M MvXr=X8Z9>sK/yYݸ`q2b|acހ@1PMd@rf{Bʉ蛜"*T\vS/;{L j`BX + /,rxS7=a۸6,FZz1M6(*PMl ++;XuIOih뀘Pȥ-̓ܲ66qੁUOQ--Uzkrk-w7/Z\Ă-? +Kg&WIcĎ} B'(in&ʙDa +&CcV5/brfgv3'Fvr#!TH<*ˏg?cVfJG2fwgrD sy3YSl>8 v3bijo1f6t)Ҭv˧8w0\NHq(fݍLm \ 7P*3Ϩ 1  6|B)9G(``IB*S|hM:ƀӆ@FJ622"äR`D-¸6#!UXe[d0 aKtaHܽ.\Rݥ[ջ`c،2r0KgA{U`*&cxqw޵[3<0}V +rfܴj9H7䱅{]k.y@& Z ?5^E1'̵ˢ + +S5w%B%^6;"*f1>O)Lc9l rX =g<-8Aٽt85ќ\;3wbV&ꊱar=(ҳ+'c b₂G8G1 rWu5C@h^8;g&h%y"\Mte8*.Ft:X0Pz*d6ycA2ؽ؞a˰) T]bqуd梇^F"'Q QQ+X(HQw{9`Wd ՅXVˬ`&;>r3-ecE0duJ(H%P ch(9er@e~́ف21ɎP8P\VbbTעU([=iU'vV; n.:Ulnzb<J ()kny8OF* G:DibGy@4 ̝ZUS+bzV.۬^B7B]9sc +ټmЦO 1x0kP6\}8u^]S\ݺ6y@:ՉCڒm$]NI؜(Q6Dlz~Ջ0vZ*1ARJc8Glzqxi}b*3u~&4jָF!M wܵ &cb|Ҫ* h vR08rzd PL6as./o.`!Fl>'CLK&NuNil`]鯜G`jfKY:ܐSja)7q2>0=?X;rxF6T gq>:9X)6:t֋FMv v X,2JR2hd;YNd>pcRP~9YȴMl\P9)FۑDgZOQ\/,nZ~L\:Ј[u{MȰ ^MIi`6w'(Fu=9ZzCמv;R5O0ʥ16+[3 yPU [ׯ5,Qͫz8‚qL."kJ +\|`Fo|kp2|Vjv_EWP]u~֏I@*%N4۝CT)eETYsq>RE4JT`n %xa V Ӎr4t &7WX>]1= ^<6r7MgrD-1VF7q3P +3+Ŝ (+ZѶmٖrw۝g[kfą`P眽=4 +6)J }#tyaHEca3c7u@бHs; 6.g!}O).>yi@+5${e^|ڮ}cG82Mx,4s{vOX'jVq#ۏ7Y{rO(y1RgԲfk\$71 z}ڌ,7İ_x~84(n$rFʬn\Mm vYΤ&s7\|$Low>LuDEZ+kz7;Sxo0Z@dK1iR,HĘ',D[**vcvDBhwP?X١QTC.Wbmљ PQ?fZz6&g5>ũURO''Xw_u\zLSs/>X?h܃Nlv#Ă4.f$.d)1h5dn7tO@to Bl\={Ӓ 9;slmzS3g/}#a¿>ݞ?^jg\μgũsxnlԍy`ɏFb*cS~D]~AIv ܄4jN x"3VN]I6ZzRNgZvohMDp+<(hyPDT+-yQHZ yƉq8CT&[R|J/9x4p͉t>ݹUL_tl1KV.wbqh De=߹q-be5d5[GJ_RAU@zcUs)D'<ǻ=4 +ܜlVp}~c_w#"wHo@'k}悸$ULv%io^&.eUl1{fvQ#A&2c]⸛8rrо +DBB0e>QE,4VǛzimPD3 yqP!ej ן:r܅Pd7tX$7AnHt1J z)vV6k!\ֺV?]{\P?Dhoנ}X4;7v/Y=IQ]jjnƎ c#Q#03_2 *7|{0y69yr "ET-¥AhaF4F|4Z`26Pq#!@@~x'$1f%,N]4%|OvƼ<)c0sewP`,D VNxkQ~<~ysK+KwW燯} x4)AT#BTblʭfu4XMq?Eq!Zҵ%0)Z'x;U_-v m3;8ى۳!023ڊd!+,1O,q/GA< >r +3n=k#8-9hwo=Qg!f i%Sll]=?@iT9RusgrDŽfq%9QN69FLZ2Rsrgel&3Yr'F+sՖ=4r7f"'1!EIr8>yEphy/[O]EBp#Ug +,|k!` Kz{ O7|`E9so۝윺bU˗Rj唚[8u{KU(t*k3#ؑAp4i~4DI5OE-?c rq|j]9\-r¤L Ѷa쾐X &Bn4 +̑sc ʧ],ka\qt4}|D>VVOJ(0ss%1^G8bKX4J\@XSMg "7$ygP #k˔F[ts{]] SNeb ;LֶD-_Jer8TMv*e{.;7n?p#:p"͵οwR\JG` h_ϬB ,hl !ZzVIY%}oGF VDum~օ.r3pco>?rwM` c1OÎ"Wna$$mZ9Lf;S{pJf/?tܫ_{`҅Lw,y`U`=FĜzCiO c Ɠ7+D}nrennDRf4cX}]N{ R~.D{zu/'H˒V]b0˸DVNŒCGNep5݋V8bZgi0~q.Ǻ~ԉ -.)fe+lT5H1߉ɴNNіցL2&D So_X~N0 +#gG]>t}5]Y}ѓq/5LP&& m+3j4wr, YΩ "HgM/OR r"(eKR>Yg\0㵻߼~-FΡO0`%„3>/Õ&wQ99D+$(Σ//Gc'|E!DFr.2ٞQJDz(d+̙|w.Z>wgvJef.-9$-yu +VH#JRBd#AXscAXi19#>P\}N_]M!1dfgzߺw[[%znRr ΙLK7oU&?Oo| )i<Mk^ҙ8-:+O_ywc|{n?'Kz+cݰ6hD *0tѪREH9Rz`VJA\=1f8SОhUj-8E\Ÿ<C[VN7qkV3|k䗗-?KVWzwW:Jqnŷw߿ߜG? FaAJƪ[/Wpəy#;T7$#bBr2]!l?kn>4dzwnkVoF8uP]WBdrb|ް C/l|^2IO1jk-,L㓬dB11b0UJzB,ewnf(WBAXJVܸpÇx}1;G* ڇ OȃJ%b@i@O\>ltps|Gs@0117qr0% &&D3X,,]FMc=%ލ,-ۂbX[Nq6!JYΔ CkAhHI/0j2R&@*uWnzHջ+Tҏz5,ͪ;Ny>^"! 3F큥DmEN,h/Vz[NiO}jouW'ϜʠG7_Koo_y0{+{W_fsvkDc;޸ީQ0"Mz:?}HiAI>~ծzA-7h}zF}^?.`! ˨R\޸vl[Oݭ?9;uF>٢rr(60D5THJVcʵ?\0JJD5Tޠ @H̷6i%ih6ӻ $fQ!2zM@k2I+7eCvDs0p AcqlMM'B_sb%:jfV|U#MOXS.D' Br*R:D*fS#LOq33OP*Y'@U + +$ͥ ZFfKHHl&jØ8-B y=I!%Ff3K!/E*p;I0Ph2^Lڜt1WY+ROt*RerKm3;wޚmwJ3N_{7VZ%[/ HUf7Rs |dv'|x]NL_zrt(xb(8ap"\]++1jUBr/)|[ Ժ~ce{`ONna2N)c8{՛a[GF79vxЋDB3HR +熨l*JZ xx'R^lV2}Ԓ9-WОOs&C zOBlS>pnb}URonqQ<5`e3` cφeẐƥ\GD/jyaMIͻdc wE5Oq^ x=5q.B))1Z͟]ڽ[śL[Ou&Hq| U1Hr i +JjmB&!q +B 6X^o/+ڠv$ 5+nFvɈ)^͓B SCaГVR| (acfAR(ge\y80AY^:--VM`iʺ^ZO3҂!NKvnZꯏ]j!\n0|dxÏ6,D +K}{nq]-d;j@P|*@a#I٥ibp-@!DP8PZۻBbt@^c}s +Y +3YBIZ/@,JDp*i-6IIt\V󒜩֗zf`$4f$ /=2:  3Td3ħKۀ@1MQ\q X-m$zI #I0e\)tE1  dI&pd5LԲ! +a'ϝ9'GdFz |f ވ"ᔅsIVBACKٹrѻAJQj2,Z9-WbJCnDkqs[ JccA#&S84Afz2ް£"l 0 T@@Ԡ5$F()M4z0CF4%!6U2S / +r>J8DlCaTQƹ N&P }sH Pa&۲ h!R (F=nhpd, !fhD@v|PC@ @</G8BIi>rMjVe‹{xy&3ܤ)"㞐7@t!-P\w zƼpiDtTC¤9݅3uZQtyQs:ALw)i1 +SA/Z?:A0b/ 섏hTX9 A,0VERjel7o DC:_VNp]ptyB(:0 +K .|LO"OM>q0)D #)! :@q.2r{ H^flIIݒLOF ϒ O& frcx;< +C2N9u#[3:rdıQϋ2543a#xO?>&)@s'</E2(ya(E &`H9bYE"HӤ$!ᆝʕD ƐPĊHW0HJ6- ˑLB'#QY54]1`aS5C2"1d-LRh$3̵@"Đ[Gƽa ã:VXڛ[PU + \UhX+[+d9@!(QuDI,oKrd6nDLS$SSx +iXdn+! CA>kp0,@5Alaېf.:_sgWw_~|{gk)a[$-apC ǎ2d!/A8a(C:PprBұc.D <D 4f[Źrdsd_oܺy۬؍F !G&H2ޑte hUm3hD-Qb1ZT[;seSrfx=<ꇀn3M#2`G}u*ѫ(9[[oo_߽j3Ƣ0j MXJNiG2q[D?-EWr^}em+o&B#'F0[>9?4j1PďpDQ_pQ{R20 +C̈7H^#аİ˦cN1[[z>&-tgp~{vje̋6{饧޺rzat"AA4' CI,k?;~ttlϯsD.NV.3I_~p}ù/0/?O>?_~ +ۍlP fbr3˯k/VnXM%֧b<:~?|ỏݜUe*_g[Ps&39z6nfI)nZ0]}||^ӯ|w=:ۻKo=<Wxh8eU$%.&Z#g5d7Vow?Ϯ׿_umrJ&rGVҧl߼w|/WO~_^,&M# =IlA\Vngo~ԇz?yۏ.~lڙRP̴qD1t4]vb'3}lʒƯvOzyl`cҜ"51`e_,wwJw|ſ;5œ?~xΤ"|~0K&67m| +-='g*{׿{?z?~wOnsi4 "e -C*u'TLzRܻ7{~gl~toۗ.'K###@:ꋳo^JkWXzҹyE+Ѹ,+,}( RNjdW3كڥgO|lG/krR(**)͞(Bra ]:ԽSgR/ѿ}ڟ>W..U"~DQ iacL?+Ս̥wo/کVM||4)DGL!IJb?_O~˫-#J@Tx5D8`TuftxTp +v%H8 #/D$q\ 0(Pf^>+;֝3+×׾+ f\ + t / *9v:|='QgK^?}>[^peԉE,UzEQ (/ F3_jlɢ_2-L~Zϰ[;S +RJ50gGǁFFE 8ACR^L:d:QWcÅd8)I$/0LƤDQO8"N)$t3*ktipv:slTj&=a +lFx htVTԠ Z.ٯ]~sSg9=_NYV4$' V+K)HGp4`/y /)i̇LAtPb>*U;g3e + +$$K鴭EdVXP +@.,L:Vv)RX&ŤgiΠp^W8b0IV>?5ԬM;VTMv # džF'TR*Qhcdh|l|,=nlԨgvbݳ/[kL?gv9 CNz C.IϰDl,+/g2mcbV% -v'[KJJD1 ca"­ :~q6xsafvP;MZ*YHHN N~$ 鞠# +zXCFj!% eLkf"LJdK8 bV3J~~, NEvHEsU VJ_Ą("ۢԔhTL4H"5ȄGØ-9 5N&Dgx1RLF)&4 +hdcpmτh289øXi 뉞 bViV*eF)¨Ƹ\)zӉ2HjX~R.*+ֶ qwH8M7Tgfu-2TaFo|kxh fQ޴R *?wsboO5):rrpyIBz#CCEQJ0J`CTQH1/7G<,e(%~<㘚cy:n\"!dOXJ0d}Sݤ蜒ݤ)TP`:¨%o!IYhfQsnHfp]LsVbZjA"-vZ+mE)o\bUY0tⳐ=ټ q!7dB*%) "zZiz#aJSn+E=iE\A^˔:8 fW6Pr$<ȵRև讠F+EϏ9^u!~XQZR`M1!G+e1)+Ƥ"&OCc\A \"d +:eӣ!Յ6#gF.K#%<}">1tai%W>50FmŪ`ihzgtxiH 1}s8::tEbStt +'Lojg;^ 㨩)%5;e9l3=7 8p\KM^HD{:U9gAR%fK bE q֤ߒR+ނt|6~Jievy=QߎOy;;xeUQlVW_lo߉v3}Ѩ;Ma8->>oUӽdU^9A]Mmڬgf sc]vV*g q|N d!LP.92gh Ll*&9?էؤG|,DT*rLj)kAH#Un(4"l.ѳKVa/OEVof[~18|XLK/`|nȅiCEZuxg*dZXȉyhrh{٥Q? @Dg +2A|B x(?4+Rcnj/y`kh>rtcGF0ϠPҤI`^Rn@vF;Rr.ݻ~Rg@9$2:4sE.:mvmvbz^c²C,_x^ƪlp9N` Keq:>\q:gg*eL{Stu30qt4{iv1 s+҃J6ħ[+wKziSc"e7{W@¸` +E5)hTš^:,݌T6Lf,V*F?TʇFLVL.+-ڞahvv{NLPC^ $VkZ=w|pIE:ɹ+Սkbr 2@1xbIe_!uÍTܜS+vs'CcP7(|xO9 +Icދa:i= +3 06dTݍɳԙz-̧h?9P+LqH#7iraGΝ{'$PR+O=sۨR'܍OLjur֍O4H)zDw[ճ[3;ORϜz[-Nz\TĚ P,^b#l|p{!2Uf.mH R߾mR+^@'$ZGbNQJ53'&wH=-JZ<_s|b{`.6^ڵ8:Z^_f*篿@Rh q0%][9x{f%9Rܾ^bmL{+?u6?}ެ2z1U[Iu`>Ce*R27A,IXcXHQfӮIN].܈ j;_4 :}ε+ T>u->TR͕ /ەJwkr`,qcrWRrkbZ?ubj |bRJjDkN+ޜX.VE*itY>:@sp)>d"en,\R+F~1D Y%+KbNi-;=Ԁ [6 +QQKE5V&XfԴ E!1# ZY$_޽G*+_<|Z[(%&7^rnzHg.g/eg23*yڳq!4Une.Z{+L%='gWNڕMBb+N~v[F~E&Zwz=#5~ac:nU +\D.Z5{!7N|xu!\X.#J0|kgyrk",R{@J2 먘A9.ǚ&0qza%@gַ 4*fe#:mfn'3ŨųWn4RBmzrP!JM%Lgrr$?Pf'=-oy n5.v6U2%/ӹ:]Ȟ +oU()U>XU_>87w6BL< +i +:iԄH3V\W(Q#шd yY8&#~ +8q%=bLB)Y+/?Uza9V]MA/M/vWB|XhH"\xHrR#ef\$3Sj={{O;:"^[]ZmzSG~ 6E; \eh/]:}.txRl4kt`R]n +=\-V[K׌2B&Vf +2lrڵGM`"EMz({Hfibvr2C4͵veFdfҺ{f{ /H .^$X^ёfvOC{pOgK@`7ax>f79DY(氳at W/{׼"rCRhI6y%#@`\X0D jƂc3[4ׯx֏|C(i6GWIly:zf$KQ'ǟϾ̗x !{/ +ppvqv~/Q12A6>݆&l8ѓif~KӜ5'_e05H dON?_~>= g_|:t}Ꮫ۟5otʤ^Lъ?3F48n/_\߽ /{ b \pg2 k6Y7B?}W`i'{fѧ5Io_5>|՘}"/oG@lwB]M?Z?ÏD{kq*yKFϾ)gk9f<>~j5gVFU +սnU.3;Iww?씹:䮚'Q~R< /@f7g DE':0RuSޝ~s˿@R(^zSvOǝS*[|xϿw7dNm @ 3S=HQnfXR[C\ a 9ӣOm` RLdF0/_}#ww=xns)-yF2'wq[%FTpQBZuw/~j^0*0kBTLg={ܭ^>8sJi*eH &DX]}[tEov Zb9t^.%ڀY!N_}7d뷏AοIOk-KFu?߼|P:(gݓ嗒;Pk5d7F[/?<{EKW)c@?OTs} iqM*lo!g[Tr:ZVLew/I{)h {>951wIH #z0)wfJjg9uy# Yg^ n~Oou8n93W윩>k7AN4/h+ Fiڭ(;_DNg_r#}Ou߁C=`U`GvL"k$-{0@hFe`x.XYv7PAY}3%^6ϧWa( ʇw%,T4ޞӾ9HJ0;;U#V߯FP_1ѶI6&](d֚^j~W03Fg4Ӡ{"Q⶷~nNgַ7?浖Lg_ο h!c-d伿y~pe#t`8ӝ:j"}2>*윲RZ-0;g%\!xw6w-szj=N_=f>3%-m4>PƓ7'ߌ/~@G`Kf2}Yml1v[ϾӇ?8)S4lH=q}e<<[|/{SB h%K'7!6x}x2*H֑>!."嫰u[w$X(0u5 H5ZމXhۼ, _c.ZOZ3XЀyJ4}w™WJIHe`:{_uwU6÷qxb*sy*36g/i%u5y?C(re$!(]F?:?t?LE5\SFهXvf +YA5x{'_'[ <?luf|B(U1RziԻ^x4EcpJLz;3d0>۠H/@frHZr7ӹ a lOtobgpxn N/T F4iΟg9Xg?2mެ1ymg~jn|OW1jeܰ;nB5bܾy5?NZU=0_j87׿u'Phl8$!Ưg=0(d:w`4{OMjL s9XJF/;qrE~~Gkx+%XTk,hyBo˹ux)cBmHBQ{$)ުFe*v4-5Ӌ??#p IAB 5B7O1.߮JE )U.[ՊMć ?١>~EhJƸ6YSE4jivjU! p&y_vvd,)5h .J43;ÄxPfO?vglKnd%=v9כ5 m7e3QO|nf{Vsvqxkd©G_^O?pbw֑ !HKN:/3Ҽkyhs#qzJFgC5.9sI\^KV&o~6ҵ> 8շײϺGƉ۾tW 5o׼ʊg5jP,q`59 {ךt##Zc| :](gɛݧڒKyk@DFGwD|x7W,XqG(_G@`9MIyS|ʭY㬡/YpFx;&U>hp6-d$IH*DCIW6ǒ%*OJ4 +V߽mld-kg:2_͎|~vF7X#;/n|8@HYQ=LU)8)=;.\6+@:Vcekh`޻g~P[Ѣ1|7/Ih#F-O nE2}m6NIIq~TH7ZlBD:]5` /R+y۝/b}:Ϳ]8<ƈq&.fZ|//sV 1,ұg$Z__(ɖ@ʐJ~f˯&g?c\ʡZh|'Ge]&q2]gXr?Nj3pIvxa7֡h!h28x;^or&%NϠi8~n'V8)* d EyMu7ۿDCrÄZR1Z Z#ō !]Q=&Fh`$kk5ڰƴjef=>ekw; 3?/l tw4Qʉ@($&bwpT+&$3b$h 'Ydp3Y3r4{dDK#ٓ(.=,~7 H9]]Fi J^A<]:u>q'`9gBo$;$!|o{ڢfqt)CvGeA%#Q=VaT{ԓ#)WCD'uؚ;DR1sG5..?cզ:O'oQ3Fj#FzFZ&g +f0rK ֒pZn(:I +fDAJk~Tp&;Z%Lآ=p`/jtghBj8eQBv UkA3NiW\2+;CQFQZ f&IFA5l>=y )B80(bV;לX[#SZFkp.pY('8kRTR <v6D#/4G91\ oXM X@Tp[xx-'/9xw +A>yNTVآd٭Ⱥ;eŴxtA avX +\,# XҕNчp7V=Kv*d5wjKIОdk6dw p,JpS5k5܄g-c{÷,?B1!AlJ2ͣ޹,x=a h\fo ə)D'~Q>ocՐSSJɛ +P&?Av^kzC@3+݇" +W0\6Ȑc0,;:0?@LlFێfY>!E0 a.IpUHu[bq6{vn8hX=|p.\ &X0o1#rҁ)!*x,:} :۾Ks%sXeuA>!_" `s=Wï@)I`X8o@ ޛ3F- DzيgUªŝcʯR]6+&Husk>cLIP~!mWU{Pt5-@ޅYgh#u1ʇ+[5Od ii{n;"dVP()r*LBRg|tP9g(14ܡlZAVF&%2!,L0``Qo@toJ˼RAj4qGd hNr&?g! /D9 !YG{сq ='@C0z`9 qq'ۇpsZo46Rj׿#]E !kKdoc~ =F!]UoJ1uQ70<39BipK5:#h mAk~8|t EG1!S\lO[d`ǜkBtu6WU:&NjK@BGތъ ճ^Ao,Ͼe#џP p΅b/C`x߭;E?, T()P> +8j\'9Lp3oŶK$B%WC{3uQ#,S4ͶɅaI{ &+!Iͤܺ^_/€ CJ x{P +蘒ۢ=.f2FsZl_1J4!"BL ^kM +?Ţ/ZTp! Dk.{01)⍨Byk\Wo hdD! +(1!w805v](d8[#o~&~ +^ᅂ7pvWz|;j̏:6VoN=(o8RFz\}N n]-0A +m`՞RUD-B jtܸS# Pitpq)&NHנ@`*E {z73LhpeTEXo*20zjH2 +B;UЏ"phs&#;>BXfAV]AlZi$hֺUB]̞'EאNˏk9B捱hMj_&aۚ=К:A0ɉm$o  ڕsBL! G-.19SwEaa6!fj{yٯANH)&p(oᴋ{}/tX}J\8]Nj^;kFV cIf?/AouoXBUt҇VO7NbUF;hcD!p``JOOCԸnIڢa)= $eP|>{{kR!1T + +Vc%sxQZ("@]HݚTlIh(i4vBQ@XnZ]_6^bȐc 5#U#MPV[ePjVQ[zJt~J WUli2x ?1!]TqڡA룗n,nkBmOHdEW_9Mcnp=qƔu.*fy=;}J^RZ6j|.B&R5⊐FqQ>Κ5w5->ܽBNOs+Yf=֟Lo~/Q.UEeM/ FT $* azF/|h}3ZIPb;l-? Gdvhv6ͽj_=wrZ;^6fo8{A*ٙJΈSPw]a֟<O/{w'Y\"|8Jzn&s37#=(L2'+h_aP3…l`q>Bb +<ӘT~>}Cf D:>E؈XaVB 6vr- K=p)kvG:Pbt:nd|xop>-\ @R#5pք+{]r_! endstream endobj 51 0 obj <>stream +svP4ae8~nOoA"db#5DmuA )/$\qq)h,aq)IrJ#^PZ; Yt!ńL6@gJ0Cج;J]ZU2W˿ +>1T 5Zϵ1'(ЬW-k1? L +5oGh ҒQ +T.V۫[=v@iqj&[:xhN;Sj JVsY-=Mξ0]_|&0AEsحKNu@F8Aϋbpkljel5f.gNd}As_ڂ0Es'PPרWqqt();g.k?ҮdUg*`=ZC0dg\2 {M&P>[qu{ä&Ar h *SLB4%J !gE9o ]A I1)#7[z S*ޗ1O٥ָD>T`y=0ȰFE8[VvX=RmO&6Ac"(+zX/A"ݴhw- R;Ŭ5 z/gVM'h3Ĥ(1Hq'ev*A 2ք=23AtZn!tP!!a o9knicŝik#rdðy {S%ܫjAh 3D9B^`rE AX"_c*-qtΕ0WO(HIxj W]^d{,wk +,,`̋$\úlW +bs 5kTֆ)N1_TDͲ雸>_p濉G`vj)F_ +H%*4ٙe0_oۿ^ O@K(̅2`u8mOj .rF1ڡ+~eemR0.ӟHS<)mk7Ŕ+9Z}vS*z81EKwzft :I<~%jwN+u[W9Xw&ŽNMAp&}2{5MUف!,+Rn[ۿm{V+%.btC +xr7a:RpZx\je`w +e#Lg U¤DG?4{OQ6ޭeDEaupgmqJUThaEʘLA TS@ /~ڗ39Yg|B ]1Rx^ߨkٯ%W!_r%CPvvԌq[|XW `P5o/>]N;٭[GH #ճ?o6zᇍpG#rGU!`Ph%j,ư4>:܈k$$?:,6g2 MPwŲ("4p9tGvvu?͟[m3M&LQX\=Dfwp. "aGG>B?gp(}wģ=|=pC?Jp1䌶`vMFZ>)[ h]Ֆ,$g,BJ."M $z#+[iBpg3c̱P.`bq\Z/G[0WZL FP8sΚPRw]x PFf;VlVr8zTg^T3#cnVY5[V*Er$KuVr׆=qo„"1GYhFxREC1U +aN6(&\(+9{=;Tdn/;qU<׃/0Q5dKƚ10wty0krq6@io&cZmlSFr\j +rZyP 2 HEsU9#n~ (>K[ad**B@e,*oHy@\JX8Bjڸ͉m1y\e>%q6i'7Daw*G!y}YE >.ja&.Tb=Bꄎn1 5S;=u '=Q<\^}g+J(,ΤFwEVHx,6b|E{? k=ޫ2rlmKބwjoBJ޶+?Yo?6(]e*eukh*ZO`QZNҬݎ]ƹbJnbB9#yca6nXeEj2 +K· O`tw+[uܪ nMƹVFnx.ZtUiBU­`TVUƇr#@:up֮z >@VQ\WvIa ; jKVv8E +TpN+;գ%mjV4c<]nt"PVgp~A> H)W(FPZzth%գg-45T gtPVӡrZ:m*%kRr½y`jI1kDigA**e"J +JNDfjsYoi5:Rw'ZUeU8~Yqۿ7z@1=)" a-XhVg:#T1 4'+dpm1rݓᓚFfw\ KcTPyk!H^v)˫C"դ*mT0ZZ²v +u܅C~*NتS|Ĩ8f[v!)zjh]ͬ2iҹj:!l{1;/6آ- ~Ճ ^tO 5ܑp At5E#T"1>/ޜRE֓gU&3 d{zs +abRt~ы&v"P"ʣ_)^l*hxF; + $ %}H6C&t(è}6eT)r(:<-zeMq>C h1N]ު0eDjbE|'Mvk*„uЫv@ (06&B.mͶnM9 #IH٢<3  Ͱ$ +!:?BUԄW}DT`r;a68=X)vK]^*UL\DT`.r7@E0QEXH[ae:GdRr@ nh7Qh#6aAB?Q +UZ%LSAÄZsr[a~.`*0Rd~ߺ 'ʄ M$F w[w3J.yc0>(7JLAk.ZnqqQXX\ +@8mW; oVqT!@pPNT1F<*dNChSyk*p߮>"Zd06ǷajdeÁhgŵ ;ע2Wa`%)VS= 0ڶKyIMlW¥(ѡ9s L@PrLHcJ3RTHNըZzxD+y2%ẁSrRZ+*>A@AlJp$%_m!50TZv45gOu1t~N_+̓]TJ NUL@KuHEH3 ew ^kNOvvLz@.f:*vSR꼏+ ppBp9#H38 +.D=u:ϣP:P ']h饜\H.'FzP&]h/H Zi=IvhOΕ1')7+xS9\V` {է3]`R `ĞTwPXD0nAA=ŠW LvF$LU +!<`.R2 +*Bjy^Kt + ]0eb7B>u¥F0~Jq/7 ju„@eh ';de75d *eIK ;Wi'\Htwa(ʂ[7+ƝlpRI@[!@=ީu\xJCk `(BH)a<΃ P:`@&Y Hr& +3VW*"BLA$Ar*|e!L^aX +Z +zBv,/$UQ I)<`TUIeLE +j + +@>8d>lvså]CȐzq2n"u$W(F]&TSRhy. a H5N! *jLsEOs喈J]2RdAŃ=;xm\ŃآzRKS=[?I P *]+9M^jѫh#؀{\6s7oy|Ɗ-Y}H "4vkhX[У^l6'v Y4a?}-s˛LWWȓ]`h eA +]lVQ_L Ƙf탥8ڮ@ +dk3LOu~C|or(JUA?D("n Y0jWX;x7PDQSa`\d=%SpR\]9WW誮9s9s`!#HAEAE$"P@Q0/5VYkfO{w5#؜B#h4Q\qHE8`(3Rк1FހSWhΜ 0cvAT88qvJj#c~1B!x#TD +J`la!…P-L'(1H~TSF?Y_ sxU|~ +NO0JZ\4)"~z279{:rQ#LiEj~1,a .&NyDX(n}*ـaTF,1IZ +%7] V z/l7xЎPqpI(3_|I7? bJ$ SI*`ՄXӋGs|!ɏEAz.D]PfJ> @qHE 2o/jwSC@Bveh0i+9B l?xxE1xjxb#:l~7P +xGHbI'r/CO*hFBքȱ7'uekIT%(xZDQA8L"0Os椏N hȌ!6'&9.I>*Z kesza8!D{XR?p@a1A䂮^6@1 ,Ќ~T 3BX)BXCt;sP ; z!;r` BCv l5MYfE7cQ-zB5VH(nʸhLr vюB? :]c&O1~ |WۜTr{%k_9W3wC֒%sIA A6<ܢ'ɱ6)mn:=}rx`Myp$h^Nhp#7V%%qd NYFvLFKM}My{Ƭݠˉ \,@c2TVF gQPDVwԼL}V?"\j6g|Kք1aLP&VTRZnL^~ +J>Tq< PP> 0 fgUIʨwY)=X)6k+ N9/p"Џc`l^7cYK6A4Y67n-|ݧBSg8m< j! A%f[<ܗ "Q'=d<(hI@}馱/}edԎ}_N *.z`{ȿ$|aN0P.}~p@;.yp~rxx^qRv7 * xv`=BA0bǍ`w[0# +8mdH!& B0w谛%'@Z Fk-0b+#7,|P̪MBi{0kIŏVwn -"T\ك$ZԱ <n!& 2fR(G +BAkLO/]\ra;Hb >y:5A6tF3G("&1>=Cqo@ךPQ;_|I;|­8h^P_vA+Fr67'$bFO@D8' l nE1lC,l;(x*g0*0qzw?胗30v'=! +6_č$;qɍzr qTv,"Z<w &1{|E CjppZ 7p@5#`@AA,xCp-$`K7e02Kc~vH +nb2 PhT!E#D!;;7 JcL^, #@m!DR}1dh= fJ۽ I/jOgB:;# ,HD)Y^R#8-*u"Q|HJ7㦁' +9v H;8%=9< 4NKO@%{n:Yd"!19@YD'8mTe$bc5ACPPgӛFzfEܴoA@ãa,jNvA>D06j-Zi&FAjmZ%]j n=V:z0(GrB=f ;T "7'a~cv!b~g"Uz[IOF `:)Hߒf\E 6;_i8 jrcA [N O$ h:03qQ@.bӚ@q PGײ "VȢlXn3i/Y[P +ib%{(%OaL v D+'a$+Ńku 8d,3ȶ6Ne؄(N:175 yȆaãsZ +62Dy@,8(_@ryi; ![́Q? )UP7=>/ʾ 6sP!9}##6,_,N5\B?xxN ; p.^"M@UJ@ |bA0\@`-YkX*{)zc^;tr4?G58!فΰA _J1&KG2*Ś9 n̈OB{׆=,t H :k (b1`,.Utrߨ( !:?Hguǁ7NjFhk м 8kֺ?v6"E` ƈuW&tkhxz.iVRZc T <8;h8+89AFZPn 䗂AYsi@C`H|R@Mm`A +q*?a. upVLxpzPpؿ +x_M}}a-[ IJx 7/T/'c=hqD(bBb eŠ{kc;#?rc- ZeIp0DMjBSA +.t C*}(hS tO% I㑢 QR/r#UBkJOe~r^A& |d3&bP9I7xo +}P؃֤%AURJ vl$~_ + +H@_`H K$lDDkgf̆=1Iv?6gm0a2^2c F]*'8 +Hq%ZQr\+-pvkUvCx +89q_Iu>4ǁ< 6eCE@U]~yc2@Ry, +(K+rōAd"ypѠ㮰w-ư >m-x8E$#<P|#Cb6ڒYVouH/r-H%*&HjfҔVg ,R0.e&olr]HMhahK֪c %/!lVWq&G֧qLmvrj]NKaH(TjaEe *ȜR cm d2˜_7@rp8P oI{1p9!b>"AU(_>oH9*9ځ(8ctLJ !@|K iSqZz+@A6#Lk $ #r=9+`0hL-Hg ؃QCI/6\*P=o;=LI m5?)|o H`Q 1<C* 7Hvqpv0 X7"FOD1>AF`h5&u? ʧFh[O&Ӕޠ +.Ya6Vb &֦5A&viS\`.hRfi!ƤL$J-ʅTnc6!ALhAJ>x 3(E6i[NǏ9;"y)%9s:tq@m !chq(-'0ӉDP3 #D'ս'X۴@PhRUNN&RG +> *ɚ=>9KKjnVH°O"68i)HrPߋVXG)1{zeI ]D$cU%7f,2(v#jx>7{a>3˴m4R qy?q>֊5W[bMxY:0tD!brmz :.kBbTV] +\VSFeV0jJ:>kZOKI +6O.(q)3m6R-.^̊H9PJ\gx LPJьJsǵVDGLHIDh$Pq7 bvU[aנo`W4|$ezMrɉ SsRzvrrԝH{Qs9hhdQ[Z6JY[L(P_4F}M.%.MR,xMں^^wX 'D$.&C{nzM-X=fJ4֠;aJKN.'O:a!Ţ$f2CՅ3#kRo̫x{G`pϧCld{ZtiJ.;Cwn]?uO EHWn72rONJ ̉PT`QUˏGsF}#>$CB!\}QK6?srvI.cJi }ez8RvdOV{;ݻ +[G-fa|8NgN5W.vn!"iR-u LLPJNΌ[@SǮl WSgSCk |n]._|MpP+;W\@}ϭ[ܩy>=):R4-xqopSFmcrkO4O\^۽De:w.?t6ֶkAUCHO箔.ESΟpgń+;)Ī;F+k!&=Z WDzoocXIwwFxhz6X{'=^P20! ".va|t)=y&Z$|wi)Zyט٫Om/db5{$^w,3boů-Bߍ5+ {rqANN;s  rz|p&ۜ];㟾^<-5w湇w 3p7ozv~b|8{O{qҝHbTWcު'W/ZeM.u0śz:U(9.9{2(p*t6ИHF?^11%>xg\-\(7Hi/7o̞VKHQ5 8p`V̄QZnm˩ʩs^N.W.BWNgvc|_|70`o5[JaW?g_-( *7u0srq7v RQaVs쵇x8YoqPA'"=q÷ +z: +"| dؼ *fHz*f$Ԍd]hD}pr؝)BbJ +qBL(nfo^Y>}Zgr%B|sG@ʓG YKRfrU2#핳b1ͻ8\=<Υq1(v~Ґ#PԒqp8*TwJꜜ0&kvx2m?sRr֬5R!:p"0)g&[>Bw7w]֊+Bv4sPzfDc5 +u6ioϵW.޻p|HӜ*)) wY).g 3jnصBs TA|j~);b'.kj2iRf۬ORKB&Dǔd0F Y)wNfG4dUؼ5S&mAmteTedc osSW%pD4EqoI5 p֯O)Vs+VAŤ|.Ż75.{p #Ub3q|thO\AcL3Z EB4hUOfY; w6]%=#4Vd̵KSGSG +ǩD7(b3}0uJJbP6 R ӑTD2Vl/O46ٳre\}La|[Ii%_w8=;U>Q/]Vjt4h;Ah9,kSǼgp! +H-AB+H'-[|{={ܨ,(H'Yphu5;ަz_$vX*D3f+a:mO\:C&F8כ;X~znXiYI $5iiF`))klk5.x(wT?V`vbɍk٩+ЌJ Q*[{X 8 b͉ 踟~Hj76o" y)dp=&kFkjD 4f$5-qzH(Q]S2Ka +9씘#RYp&|zBdarkyRR*Y[,\JwEs`B6b6rnMnYzGJ9}*ȍtHĊK\3h1/sZ2֭3N>_*Ϯl_i/ecMR-ÀxI#$’H&l.KŠTjC4`ryD: bq-Qַ451A(97mF$'**gq)I2u1BiM5f&A6f +O"x}+V쥻 Z$ jof}ʐ &330>^D|͛Fbٙ[zk3GD]xL,ry^#D hͥ&KN';G!/ d-^E? TXJ0n8!iQ +Jj&VʃFRD{S,s!BŴd2GEr1R$R+,ˀcъdqr >L#@a>dlmBgRslE ?|Ph9wn%&Z%Z/ySrꬔҨsAEOyElN+(-dΩ J*`\aގa2/9)3Sܨbbduօ#rw!܄Lj9R u|gAav"I͊K{\I&RG^Zt΀͈!ĥZm|53 -V͛1Q͆h"f$* 6 S[z$kfp7wӑB,=C#1 Ung\uxY gT8Bp0Zm{:+U|!EIvK汍}CO>ԳO?_?_磿|߽sGX>d1-7 Ot=?y׾y~7^x~#6\.y +.ZJj9-QfA;~wO~?Om~ʯ//-(%(g0bx.x:]m/l.>3}:;{<~/~w鎇&R*BjI..TvXI5fT{.?yw?zz_ȷmGw£()e)ڔob,v3jo5>q{||?0,sgU^X׀%n6DDLDi2u8՜M5_=xow?rϣOw|cW09HJ\$JWfT3U4o]Y;~3W~/~~{~طw4],x-32BÔAGҬW'wO||m_#O{3@G~)X:~ͷ\7|W~׷>O?ɫy7N\-T +#dj|g/]rG˯F^3?~7zϋ?cO|}lҙ-LX,_||~ǟ_> @^{n{a/VaҼʔ[ݩ]8;u|?|7~?_>|/4#Zfv&ƘNf>v涻o3=^w>?yÿ~^owNޞk +Ѧu8cǻեcGO_k?O=w>|O~?Cwo} ɷW܈pc}ؠhӛ{z>y7O_z蓿 0j^Y<|ԕ}G~?~7o|?_O>Ïއ=Twp&ᨵM RZR2L[lϮힾ|Cz?O_w?_}౧na#3Ω-MM.on={[}~៿_wyvBs+NaL5NG"l7Gc<|7o+|} ?{WoŝmŬR{6UڋK;[;{GvuW~ʫ?k?rއ}/_W{yh <WXQΩs8sĉSwuK/wMo{|}Gz^xի?PQ\h//bNRJ=|g?k_~yґsi!d$!'ZDЛ__XZ?q3?~/o'w?o^?ڣN{j'L=a9LVwW!='^xW|7~_?{wz-ߗMHzΏҞ %t#묮~_{͏ ' ?}~~|wwN|sAT %aӫg/r߃>+Ͽ _'ۛo|я._ut,7(uB;LjŹÇ;\rѮ+a(=>Z R{755={ȡ#ksᆱ=x׵_3WnZ^7Hf{7CDVR艓l.JʱWo>W|O~g~셫gVVLAKh1R*h>~w7K`affg}<_?O>yׯ'/~Ge= )$BnA!&QŨrGΗ_xÛ>sv#[d''H2R50`/(tәZۺv7{{O?O~|׿[_bi caϘ=lwbL2?q+z} }~y^|J!HSڬRT$)>|λ~'{z[훿/m~} w&'q#;4(Jޡ;o_|'?{'}g]߽􅈁3zנS|~4r)ЬMֲAorunuQ5o)qяPGB!ۍF1PFC$-R% '84/ifG?D9_ )xӂbQ"Bެ흺s8],BA'!Ə/-NN\TTd63Sd˗Jvorq=sɉNWuQJQ^ahfKQq#ktJ1ϧۛV-;55-6"%EKE2=^. ڜO2HAk|jw~'xl.NO.O*#Q[!G<#~xY>D/rFD51 WX6T:S٩rEȲ zCpC2`T0\TL"&ʍ٭kKT#as%#'iBbY%5_0%glXl.ьEѢnďhvpv9OcU*ŝ3(xKV)rZM6#H!$NQFGMdd/@K9qgD3DKEPv/EY?.F1&x*% !Ap58M2X9nD M4v2d̚.M*"'ąp' $k*zMkl$ϩe1X^ԓ6$-)mn*qP=^wZDIҬpqXs9ZnATUnoQ?m.9dRmzETK7NBix4tyIxC"NŕX'S[M>6'hzdž0 !LR12ӣ6tuA+P1ځVW(1.KWVtcA"forZq4VH>SiWƏ #7SJIJV $ZuBF^!Du]-,H+3ipɇlF>6iCn @0QwsH@]6E!mq`x1q.fL_BhO)Qt9>${T P)"R1&wߐ +;]a-dQ!aqP `/ J, TT RA;>f~ 2zn2YEYs 2fؤE yB#rfz.'ln 8},dF/|Nirj^;:9P[ۜCfԦ=TZOY11&% 'O5/BуhcNT+gP*~pK9Z*jE7[vy#fY%4)d#D;N7@2v'jØr.k#iNkJG⍧6T(gKV "պJypsE8FˆGaj01+epBe ݥ|cj͵16AJy(w)VT)!DjThN9gA\C-P #y>ՋrjrT$؇F@,XDH-~¡A">,g~p̏ǽbQBC<.#>ΏǘL0G\+$ |QMO+l#yw@sӼ`}C0i*iAp)>+yGD`D +a&S+gka.WF PblA >1xm{ʼn>ZFٔ+TN4:jg;ђP/=lhMk6kUW"Y_[Í)7Lڽ'z;{צwh!!7%Ji냩Cǯ<NTrA"˙'' n}I2)\a2a.'MB4i-G<դL)ĺfBOrA5x! :%Xu?h}A$g.-e> +(tGC\P:ޑbm)ޅیz^"!%b5N+:QqNRb+ \*z<'ď3] M^kFmB ;Q 0Wq lQK3[ta^XŸgd?^#ӁҌ(cAa*M)> +ǵ1T`!*o¨0vw{п/7梜^.4p[jN1&ˌo-]ͶѨHƜQʾnAh1.? i0aCu _ܺj7p m] ( +] XC!|!B1ETflo1)H"y÷훈T$7r f~!U^x {NNϝ/8MOWƏGCŚdb6SݝL778<ŧH.SLleD5cU{9DŜ~@;PA2i5;Ô1gbP 0 <ȷh4 Q^470IhE2\HEiq$?fjMpRQ t#L(3BN JPJR!tRfH+( w0T`/ ryKp!c(c<ŋGAqpl8hG>.(.]91!]hS=@*g. I^Q7sbPDJ,i$Ԙ $^-sJӬRJTR->;PKl43gs./I/ +ZO'XIB M|([.l N +`$LNA2!:<b4YpQW63qp>*kJ&҂u^- % Dx{GíX}D,vb|c(ƚ^eLmlvb/=DiuZ_|wǮ>#T0b!De3:F;jW!lJ1dhe5&03}U!4HQjWm^㛃^O#ZZ\rJZ.pQA:Z:!OPDh41NO @,mFf7(M[a> d$5kɉa.Kon? 0\+.&F7\'ahQ[P6`bTHk0!w$'f?=͉ s#UJUfbY2ZUr3L(hz@˥OJΦB$]p.+L S.ρ3bK+@.-?}yhi$Dَ$:h64r amJPJM/.h<ϥ줔6j+bjT{_??q9La遜8| |֋v7F+dk3\䴒UfhBfS3@űy)5=;|yқ~.m^,wM%bUPU@݄B{'"!7lE 2[Z[2HK@)E>ڈ֥x+k@[`IT#Fͅa:@W=-eT,Nu*mټ"Y'2"D-0\(FA,y+  jta!+QR3ǘjznAabVy%\Z=}jrPщ=FZΩZLe8āN)Bӈ\1a/ DW2+K?r +k¥dc۪&;ƶ:oDk= ԛV+rg^a8<EPHNwhɶUY raJrjjxx4 +Tm!`帄Y\dژEW\2V_qlUN7/gRSz~4J{绿<{y|luq7 X1{APc7x|M.ȨbU{7Ɯ$E91^9zwP]' P JMI<>a{qdq| ;{~rR*C%ֶ~rg/˞ (fQ~=h/dBCl!Dn+-`%eQZ\q8zf6A'&"]-R SJNBG^v>o9pDS-PKY-^!8ILlU.֮bN77l_R3VIԶjjP +#%=3=91 խYX5JJf@be&\jThN\}ݼjkgVaPVyW^am%&:nұj ӋΠdRvs`,hrrE Ijj֤X<}hM <0/* BΑF1Y_<$Z>uE*Oe$Zg iJW WjgO{pvF"ࡆǁ\ܷ9~oM Vwø!c>Lb;ߙ?__*g\xxȎ9 >W=BJ6`WH^ :;*57:b 5P#n9>6AtA+ z;cN !9GG.T"4gjRG 6!$xUQiߌPYf0Z&Ī.tz킛PBgncvjU6:9$$I(1D(xm6@8B[H1)S\ ri)9BkEdcmzRsPSAW=R.26BxqeA R]>2Ņh> =q%FE=P^:e0?gBnA2̙Sƙǧ|P\BBP/640y8߇Oˁ0G\ *,~x^K‹ +1`8ƨ>rr`5QhzY&"1"Ƨs &mRm=և(ǍdeՏ*/8 .HB! @8UcG{ x ,FV"7 31 vA/n%MJo6`! .O\><罐ΎQ3 1E(62!ZPQDBU2>8wEJ- k==sjVvirW6.vK>ζ9rw_y6 |LJVnah` 0)1S~" +I^ IblTFO!LSrRm +*3J+VtB.z=!$L5"G(SMH} |T;w嵹[Uu#aU&ù6$Eh=)ediӓNY%3z͹Ok33pd( o`r4a]HċЍUvc#1.:0q6BPs.4)cf!2 +y:Too\ݽgY->ޑ2K&Bti0rV^f2p<<;:CIU7aT %%2( SrrT> P^¬aԜijz!b i8 8,!GlHv`Cx2֟ǤJ2bRi]xLKTiiqj +q=ՍַpK.F*K@e q@ M3 _@cm>DVm#uv + 9T\ڹ)gf>" \HtͭYD+e=:/K H= HT5&A֨E%hPS[k#NnDlNaZO;#G@ $kBO9\M` l$eVY]OPH8u i \^P1'"vĎ8O^p.S)˧a!NP.};+\r|.,/ +8bbu܀BYQI?t!44?ρa ZlOh,W,^?r1<񫔒 P\$^X `ON Qo@ÊM3BKCya6t&G' +s/sf/hRPjV2[ux3UW0c1Y2 4TVnw+*kv6&j0a;!-A6달F֬Gfzzy܏g#9=q$7^ "*E?4!ԑc=pv T[sN}옟q~UZs1G!ǝ( *&>DŽ&wvڪV5֮[b-@婽mܪR|b~AsSu@Aq#+Wjou'Vbzil*n\Y#.06ɨBpts ^&(m9O7pB5L=(nHu`#3q)I +Y.4|'[gdSϗ{ͭfgf_-9H)Xy/^Shv&"|vx\8ذ8dNg.᜚ ::B* i) GG! cQ\SK\tM|lg,t;g|v d&[`a#SwxZ#[sr,´!T!1hD-8fǞtFm0@#ٍeo{ޢ"dXfiM6W/@p>eff ؼ8 cn/ GԽftel`i`p#Y]W@£vcQp`1 +naq ԏ(&)1J/~~xσhdmGKk`1Bρ +eӌXbĢFEJ,-}~@>L@֏  X0*@倣Ʌm%`.N(Xe3VߐZﰽyŋ=1RrClל>vpcwi#uF;~*2j aSVuyԣo{oqjy3(0wzMO/'ϕnə9ZN;-91Ň'H|ƒفċC݃vgMD hhWۏt£pmEL(sgf&o `vw"S_bd#hIΧl2^!D(` c.Zn/kE92¤BHv-=7ܸpp1>ZSΞ4ș.?l~$EN&;\޾qt} JD1j\ui~m'DiK|u5T=1@.©9rzi/$Q/@gi) Rٙ Fa)ߗ$gׯ`Ru9?:!$Gƃ 2ˍs^ C`}"9 Kˍz΃*c# %5WݹOțHiA΂c3c!228w?)9T4ea3jj*ެgk n?xJyffGkؔlobi )͉)h=$ L̃#>y'dž${=$Y޿5%%ֶ݉3Q{PؿH)ba"!j\scDa^x栠Fa!BNHƃ_\Dy% 40d(nt׮fOKTX-[u aA}NPbm;6 Mk0lLOTedP(U")$D˳q' J@@Olv0=ZeV}f{șED,Bd|a?}&7mw3+H>+UNkZJ2ӡDG6vSAXFPU38kBN Q/ ;<^"Ϝ~,BɲVCy~M3^[{{dK3<-B;a qp?`߾8plp1;𰠲0B՘?P>2iiU)l&W1).4&JG)2j#5H:RhF +ޟ|O9hd>h(a (嬜]ۺZMOT/{f?agK'7B| +RmlOrarNHg3TN(bTz̯H2Tu Dl}z1w7[![ ' XّqL ׄP!F4,-\5vp0R0c\JON}ӼVC/%Y )ďv'|c5c^͂\:woyG'{kcPAL3P&dyوV]n"nB1ȗG!60(ʪpPG*6ϯra}Zm,^,fP! )BRܮW0\$n`Xl)7 b97{lFײ'_޹Ǵ &=pnfcJ6J'E۸ w#BBk_MPmy疚jH/Df-,omQ:c̔:mmy&ROa7fbe&>G0'vҋ: rb(pL?ݽZUglOl.@CdQTڧH4v、:6 dB&zM3MV5HMzK'/N~QMl + +$IuȆ!KDlzb;ݛۿu)x~(';nLe1(q.*g +SjuyA5W /B^/y>sK]o^`8#\ͅ`V@aVӝP*͕{vjta[VkwTWO'<HuQZLJDƜ$Q~fzā}3l 8Hi>\5﬜qDk0jA6|}!HV  D X0楀G5b8ƙq?oss@GRBND29:A +Vzb/vv|VJ6vOݜ;~.@BD}5d!3pSNT>|h"REO'(lKv>TYḙwodaV`^5 3OzmR;ڳxm H\2PWbtRJ̲VOL.FeҾu7Hʻf& '"@KzP]~yW9\ "?j3{ &\loؿWCe)1!FV/V *Tƕ"))M2Li,Ꞁw~؇fr7BpE3͵3|h+kg +sw@B?nqթa;5K V;\ZuϤ&)!~擳Wܸ5i:uK>T*+ udؠbsw td퇥xfQ+c\F&MJJ FĕdiP2ܻ以ގ޻틏>]8Zwν׾lVq⑑ ލZbx]iDj,>2J!*Ćщ-S3Vs+8.gHk&6܌ rghר % +nR֪D%aփ@af~}P[Dk|j[K'j8޹Dԁc]8Ӛb5)֡b5 zA#ޡ1 +I7hˇB&LjN? ;߹|홭¼bռA%TV5jǜ^ZL۽Աa~Q Z4?lZ{闚+*h/=}of#&8B@z6r.?p)ќ-` h&w^z꽷Y%p0}9C=5$u찼99Yf`rt1Z6V Bg**z1wp{nRuv*/@IZFo_ Wr!D)./JZs_~o)5;:.o$n(uvn]>ua5jfμ|ۍթOÛKFu/ۃKs0iK7_Y>YZ8NGK3Ջ{_xnR:O>#vt R/gb?.|#18*Z3'x~]?xfo5XH4 T7/ܬ.OL鹎 +kw<{u?mY! VO?]<4U[\zn''VK;u+}gQ\ \=vk{3{ +ܩPn k.XAxMU#7rFoSЋҲ=>0əɭ3FFD +Jgjjʢ|^Jp֤'(ʡ,BG] =ao{XR̦'6Y4LɷVgN +)jf"%\i͏rRFk9wcʨcj3^Bt^"HKF1KLfAUvJR엃 (ǃh &%a´h$a;ڟɋA:J*Y\Jjf,* +VKғJrZuD'VXsbFTqcJfN2Dp|&}:&`"`dB|*wlk3F*^Jәj/VX"5%R7-B+ u!\"?"DWe6t#rjf@L.vj{r΃׺kj4pxK~2]嗿s{.^8s[͵s;w+ L['n^cr4õ9JɈj>ЬKkޜظigo'"f#JS'j.~';lO*m_uPvQ L,:[˗OOo^NeLq(t{4JDP'X- 0 ʥ+O~\ dtTH@@ qF-z&vϊ9L,kQ u`T8?ˆ?6䧤QZ]ܥ/}oX51^Z9e&OXgWu1v`.T*AC6jN쟸yc"pt#-AȃFb7{+]V/ Z-e:Hhң@๐He;ݹf{Jz!Bnp4f-YK`EXNP,[PeYp4^)-] 6~6oBZvh~60RmB~+I>T`q ")k + RpW +4 JLrmR ;|Gh5-E+7kD34mrB +!Æ(.#4 b*F04Y$dKM l +Jڿ\7߉ۂNddqIRZ +D@tpPB& p6Rsx-Y鸕I&ZYPpT"FAk3(PXkKkT7Fw.=-/YRLqvy:PT?BԊN7t!tGcQ`G,D)pG?=9֬~y^hQ! $د qY-O,y ^[AuyPI@93|M*EZt{[7|T J1$~q!+d-ddVlmbr0C  T0c6<LH.NKIJ0SMH)H53P~IuC33$z p "A!dNPk`ܠNIê}SP_k+>٤  Z+|ZC&Pya HBnEm1gp.76B'=6^! (D0󌒔l(O +ZA`4 K"f8l̏8 dRe90TɴpxRj(GPۃ@nb98\.8cj8Op(7QpIJb1㕁.LyMPpdL0BDl*9=J6d㊞PC) ?)tc.#0"+$ABqPEn7NDr Be0ǃa `T&08ΈYUCzpGBE2 \N8G./nw>?~L ݌4 Bഖ.$K6-`EF +fzo\ m60aL +L'(>c = _-`  hH21J9YnDu&A0.OÃ쯄,V ~pF2Wz@4>1 l6FAa&5~IގbCq10DWqبDRlA`14Pdw#nP + yFKn\P"( tRV4kzafRQ…j/R#%|Ax dP*j23 ;1lcyH偩2`c$=}MLeGp!nV T e:p/BAATJ*YH5HT4 G< dG=6$]v-Z.Kk+V5 +@|:Y跊P5d*Kz GSHQgzq#^ٽ.D 0BUN%KR%h6d7zɱ1?_<#rΉ[c:ݨK^KH (!3B +BQ"x |Pti D +gh1:A#G\vDdž=!WfXKVS[Rމk`|Ř 4e"Hڃfw`D/(_ٜTQA;6F:'=^!@<0TAcՆ|/ؾutt` t@ e^Sx%A!BHRrҢ'?W.TrDJYI6O^^޹CQ1.o<Tӊ6Jd- A0ϊ!8VRd0 5JC0$ ǓAs;‰7\Ұ :zl4*hC$i iŪ^yP oe- v68u)& +q\*y녝n&~7eQXNwS! @R©͉&T4H yBS@^(؈6  ^,Ns  A쎎:elRHF(Wo z|<{to##/}ay9NJaEsxA ]`$)lGdžq׃s|d=,z},NZ(aMRҠ búɐ͎d9:9P(z| s*LnҲĉΗdT h *H@_hj22DŽU^L-DW? j+z,٬ܻy^}jqG,* 9"N fJ9|xb1SHEQFu6`Q. NHpGd b+T"%SC#=A08tiJ;=i$@BRf(q =5<$]^8d`0e0R,YT5%BTdUF8J/.Q  0Ā'=~AdP|n\!En \A+/^NZaQjK AM AJب5P& DQDEgiIF2##/;:>:6؀10z(;D*cCt4A BXhoL'r\+4_Osymnh8v|ӬZrB1q\(IrBZ'UYf3L) K2I tc{n_Zsu\ɎF}0P\Bd$.ý6VռwK\/w.{+TUA<`a7spM&L K%ª4QOv'VW<~<5Q {nV4[8 9H: +\ C}hB(JٳN'V7S S>4bDt ,'s\&/劉Q0X<37LLW“{'n\[H#$ $ije'tѩ'#oXw~`knrd +pA/"RDnJW3 Sw/Zseg^wVIFuEQ/{}OT-7DS dIZd4c#^#^E{35v0qL}nt_WNgq<.7`{^ f{8d\)ʟ_>h9z|m͙/?ٙ|ѣ4t>1#`xBd`1'${Rvu +B3̚yљn}˿O^YDzݥwH;#Rv?8ոsP9_>WW~po??_}xOZEQDҒ{H<ٙ]|VW~ٿ|W_ު>.q#JF0~j׫W^:Uytw/o?Ͽ?ǏQ A8 <$dvqEya5u~=0:d=Ͽzowܟ?o]ڙg,ȑHBfJ>ӥ.i$?'ιOnѵo>jA@h\3*am9Ky.rᇏ Oo>_O?;{_O|=|i_V6ry~O}xOo/O{{Q3\!Жv/+g?\ɳO_[''ӿp:JЀXJ7M0oN{__}~>7w~ݛ|rӻfOvE*2/c.GJtϥmj ޿իKXxvx~^ÚUhu2syf1Qܜћ/?gWW~鵯_߾_ֳ`1Mp#AuRةp|\~ٕ~ \BS +Sv]-g{'ʟ>> {|wN{ocg2Y"qBRDDL:Tgnl~?_|×6?_tӧwNŊnfD5C&K,gJPxt^HYNtٲxiSR}utǏVwg7o~ֳ{+ݔ2>Bf +IbJ7'cNwү]<<ռy<}vοqqBr%DdE=jl3#cĉI`M_\->}u'roE߬Qauk +g׏diz쒜2i»>>##̬,\4gzf8rhVĒK AH +Nt[@!+M9y=|C +Y%d7_xpTLB$XE$*}e^U?~rd|>WFpZf|W/;cWovw~wώ8"T2@ٮ7UjTUi^/o·_=y==7{v5Edp-cbQxɲ6VFI(~p,p'߾_]_~}ܯ(GUAIkLHa7VZW+^/{OoϿz4m8mU^ )Z54oٟvyXy<Ҏa9Z>ci-tdU`bENg!V4eue1f#XV2ksj(eEL|$pj{=n(h^SUbnrbOv556RrgJL$_M4:r)F*F%Jϳ8)6qʸ2GWB4Fħn#/[c+Z:/2J1ydTQ-$C 7VrUk>z3X)>zvw.{ڷ3̝4U&lAmoeHƏCvXB-}K'iZjtՃ"gQk3KT ھYijk )(`F# +8K kp1=\D?>#`5/o=:c %yC5U5Km2_CV}僻@Er>Z!R %{.6%OB+vQ.`49R9!՝ƺ>LY/;3zh.pr*-x͹SLZ/Y|G?!V+&g 6$&e.tt(u2$xr2C2g(y{2,!jU5w,Cښ"g|Ca+2Q OFcD("w})>6F*ȼޗa0Q2Yc.)60<§Bsk&++a&>SLgbI`:׊-Y,N^2 X@jcbgO7xWۭurLnkmc +l?kZZ_U/(_a]*R?% *w` ^+^t`?6ZWC*mjOɊ~-8ch|Ә?xݳ{z{!8Z^-o~Zy{{Eǔ:Z? &O{ &ǼWoO(8v኏V?_x+џ3&pM06糠P&NVa IF+ +CsB("Fj!M +^"2H"mis.=X%t3jHqP) A2Wew){S[FA)35}Ψ<5ZX{'&u-1㭊LMtE.65hy :;z|@)MWg +v^PȦ߿bY,f +Bb`+M޺NV)&%!os%hRrkPa2WE婫.'Kh CTg Kv w]?e2: VY R_q'Nf'rum4O?컊rh6.?/ Q0{,_Յ h!SћIю7t{ձZwq`Pi:v?I +Eo/5V_WR|(]sx~O|qWjW@+ɪVjP;W?BlbMB[(-:v:DW Ѿ}hq8y׏;hUC84[Btݜx{96qQۋf65y5S2]ť޼ԫݓ'o~QUH}NK~o?/FQ_Ms<)nut )ieSۗYD(3UAfں?@2ze2B9",suqoAbn!6)}ۑջ?eޫ7wAQsi{9)NE|]_11R* Sa̡ +iεxx~pK=>r39>;'j4;|wzc'w>x3^^8}IQgQ[}=>|HTYu)"nArr`sgږ[{%Nyzl6 \?oxAY={B[s}?g70=ݽ??=AnEo/Vco"|Kς㧿iae}|]lH+{|Ƨ_NymDr+׏`6K*'R'F;RC}=q/@!c)m-v߸ˍT\eݓ癊ط9WBGBmZkvNm]Ooz߸"P.W}:םo_ѓoG%o`Tnz?x?zO%wؚ]/Hc!ƘqPKF봶x +Cj-_Fӗ̓oUp4Ϟ@>&_x G_hr4{{diZ y3=^KѼ,TƑ:\?jPF=_Gw3N}(JmJS[G\[sQJK R86:Q׿d5^ NuNކ3!LJ^8V=Zdl z<<\ks9 +:gϿHWo]h|O,P>>?{sw$Zsvq/?UUj{Z Yѓl3k7sVJC&g->᪈6JƔP6,^.*&a1T# eA%s|"bß,gt֗_^A;_?yAv矯^=>Z>{%Lv'1e(U6oq=_ςOL͛/^ uH h/kޚ"T*'93mD?#pe„VBtFHVnls\*C3ʐX2FPQkugN%- 5FkpzqۃGX?U+ 7emx^_ ވ^cvNO*U ß RXޕxF/ZKwpY^iե掎.]M=0U\n^$qkϛ[iTQxnٝӰs;U!& RnBA ک;~1ƀG{FA6c̓$7B3S7s"xbqYYv:.v!qθ6}Wọ*ojlō\n>-Ѥ6X^|/* 8T_ w. p:g/ Pj7 Xo$b(eOE7Dĸ(?G7m`]uw%2:GLQ_kp7Ǽ ag͘SDlAn.ËP#a % )\b6Z7>鯟9KR~6} ]7a*T݅!D9!|̦fGd.84`oUhKh+o5ʣ/,tr9y,FPQrt),O ;x3;ӓoJՓ;Y1_ hZ^\{ajV8;rtZzm B;i<}.Әuɞ+Ɔ=o¤!!UP|Zj :{Mc(X.Sy#(;Tk0" &*esxWgu5(F[\@LP^kBLA3uWelE*ynի[d[& ۙ_bTŠqŷr2>MCz1JL]~x䧓Vs=>yCB!ٳ7zgzuBm\jҍ.6s vy{*K1ZZ7@+#Ml31[+\QeQMxyTY>QEt(^؃WX'OIڭ{Z|ndfNa6*v!U CƊdJQ R+q~ښ\iL9.)wL"]n(& #}\"&`ʳ 9)| (+8ϒ')N_6wg5vj*ٲ(q6f|R^":!) %Ŕҁ-D +D@RDz@s\i$ȡdR;1~2 DéɝCu +a{g9LN?̲wG?5h>]|wN7ε.dw+L(F>ϥ`v[8R6ƘHi_?Rpj_~O`pq1 i4l7okhIE î!6(90@dAY?~vRW>d.[\]w9s/zHíxg`Vkv)g5"v SB;'P'Re4~kZMt3KKfw#ruOo|On[ݏ d@#$8Aj4Uxjꀃ&u.HjhV6 _.)H;}DFH֏cRBaP5 -"tu;òbJA&Olo Ǩ|E*bEcg5-k E6F |:[wtZUiIjm%Z[J +Er!WƫN(mMJ e] @ o9"4To"q8{y__*(aXե. '8K^U'ϭʘ-XVz(W.(9E}lpV 9*s!%*4H3wM5|7'  nmy"x+%:xs$WP۬5Wnrxm<%ۣ|Gh4#V)7'9qDz3ɖhgR+cwpS(aʋe-"F(odxx<;[VJh\XQ|E+c.聆M 6BRƘ'1}L*gao :Rn["&Sr9}p탯9o)R42(UhXcHR\hΨUK2Fyb֪ J +a̡;9v}jH&@sZ$ڍ<9طׂ[΀W+2eHoDQ^o6(%Yx$:Pr/ !XWv;Rgy?mk^2NgxZ\ /~]~՗@ɲtu_0]V8kL<Qe.va/0V!~kTV3ǥv&YBb(}d=LnUH"Y#A89zJ:BStAH4Ǵ?) +x`FʕCIɨuyW?U*%cQ" AɌvJ]JqcjGTQ!md3P$OG?YDԦ!wiuhćFH vt*z'e>er @ޘR tUng"]+ yh4azLSomw2RBY=n#FB#ZdVF]%>G!CalقD5SY۹wQk|=Y7S@z+G뭰y^=꬟Ymv'k㇓݃/K z|w,WKUB g@HUHDrC{<%UMRXj/=icm +rCuFOӗ2l(:g 7hY.Q>@=}Sajx]ۄx*tUuW~k*I#+Z3}aq2kxtpֵGrmr/w3d~;qRx4kNTpM4LBhe"ڈnjIT !L2Rk4 jk+ +!IЖb6Uѐ@i9{8{)&g䖪`;ǀ|]qj2խ, +6SZ 72M"/k;`^:ˇOn7vBBYQV O>odxBk +dqdoC +VnZ], + +0[]Zov1.ǂ3Qhh + FB2h#oX綏Xw%b3ޕ8_RքЇ%uQ]Vxs*xkJ| +RP@"W@=孅`ϠsIAcBz {1}Lӈ#X‹K5ҜF)Ri2=tȃ۽ !d0g p@րyi*٪ G98.y,YYCAkdL *5cDBP[A7REے{G +r4nQLwMvlce6;oA6)9c)\w ,SThArAB#mLsݭ,N c%-f'wTAѪYvB6 n#)[dKUCQalh]f8cQB8HVvc_gv bQox{@1gtK@Pc%Xo)t)*#\ۀWցMh+) qp.j 9W\TJdrnV2Y@8<@flf5+2q 8{ISZd4iHW$ViM2%WgY)D,uA&P=snNvZ'ƕԺSE62Jߪ κ'\Č}w2%-_10JJʅ찑fYݘ\|CTݴX-SՍ46v߻]\!HyD*f2"\-˹>)De3,=Jxd|з"bK2 D*_ySR!jPҍb ;695pLohr5YchBq&jt*XK^i06`l벽 &H=j/PWZ79i }Z`Zע=/":únj &=֢oJgs`xQIrUUL'x?2(p 0RE [Z'#cjJL1p!To^bu5&)<lUh8_V@T9ܥ1,}zrw.B81v wAͭx1)Ip7[)`krj9Ծwf2FF(U!QbGKE ]4ng,:P1J{wSvMeZn#T<ԗ ۟S(mP dHvȱيʩ}3!y\T\pn)hC@O(Wń@c]I]\jj:܄7Od +$)D8QFm-:ShRP!~I5oC@`V+ +J9IfM-)!{HIC[Or3h9r{ њR,R=&s,9*I_l*2,0BW~62 d +Cu0tI㬥HZ=>>emsTƋ@J$Ҳ/sBJMbj /E̢j v>s,#->HSX! 1MP BTP౲>1F9 *a:ςZ,ЄyJPJHViV۲7+1~txgn/k- eO BΣ6c+xmO2 P-4fƅxZpQ4!40!!ն@:&%.aT!8䓳fby cg\iA>4P tI&#V +Ȑ9­-\=NtReZ;W9 +x>.&=>g֡^MNT{ϗhRFxKBc|EyTfᴶ` C*`?Q)NZhLWLh*a32£_՛~Sh +1qud"Δ7nj1FmT*Q!01n3*|@e҃T`Ba.`l`x +,Q >) `l CDpJ E  D4Z!^%.Bvv$-7vtCNoaxAQcΚc +cX'7~2C#01D䮶"?0v7flYTb ]@n>vȏ$[AqX鴯vhJ; [ּ3&%ܿPQ?z瓓Ͻlͼe2zk2*=FSJe3nLAP3Shɵ$kE- $TfZуN63Fy`6ݬ +1ck +XTK_ #ob'U6 FA!TѺ`|Qbn{+ʜ0k E.%*$d +eQDjM DMNVhޤH:(bRۼYEZ3%(SE4Y};GrtTakN%߹9uZ:67 ҠwnJ\39Ր+0o{ CX\cQpq CI'Նqy2""v.p:H3I픠0 +,Y)Jl XTnLB~vE6a,o-oj#op%; +j,@j* +@V6`QJrvIOБkhCަ ڍ3yO`wv(_n)8ͣ{oxR;*cvdwK57E2] F͍0& ZgϺ8çenKJ s@ +lT#r$+Ikb| +p(YLd͏=^K|탡 _rvhO!,*6 +zW'ݓZ2T mɉksZsFIe;b AޙR 7+l]0$t%*Se$`v,34Yjv2^)H.FvGanPr[G"i0~23.(BwhNU5^ +#VEx2Br`wKΜr6/PWٲZ&<[1\pʲB`` >9)1EL2~r ѦL)uEލ,mZg( X)>%H|f`'+Pb*.V s.^p&AcXvR@<nd]1:tkuw.(}UeNJMu!80B=d N*\T$mHJm_1E @>b dsaVmb5kwv[m;OEZشtI=%8fCH'=) @^bMΣ +*kѝm.XcDI1%ç^LϿϋL#21>„k. s egUW|Sꖸ:'o'%&Fa>Y\ fyB 7ڒ:< ;ڙHGd<}o3=bӞ` *urFlƘFzm'9TKISTC2cHz?:Ĥ!k,[;&:JGyChA a#ΘA@|HxJno͂RRؽ>[޻|92w &P|c+IB ٠ "S-b\IM2c|c;'mela%wqa5noa A0交+kNmV4P G$ 4S%nU$B]Tĸx0t?Wg9"@Vnz(}Aմjx;JoQ8\AJ4Dr;z|Ȃ +' h̑fb6|&+@kK!=Tj ."wd2fFd Lu->-;yD83-<7;e)Kq'+®r3*[%A dzZ} Щ$1PVzP +b#ɧْLJ-SJmx9T{{ y67v7E! a~r f;#8.%[Y!U2l%hRifF(}IVj@I~EiodR LndK=qo' ӟutq F$\ DGgP0 2p\ĶS"CŝZp QxuV?"VE@^dɤb""WcLp$49<VG5g:w +Fsߌo8k#]RIoA+JB}Ѷ"Vsv'MIrhܣQN^_N΢6޺?Pyø)|HA*Rn~M_fpSZVDϒBQ62-gV,R!7ڑ:Mb\f9@d@j pzf Bii2Jwwgn0Y8:y.HŊo2&,9?O h\hħcZj{U¥O+|4v*bL7ێȲ~@XZ*n֕ U,V "#hbȋDr=Eoz'sIG2#E2pyϿ?=B?~y)OWuuw"ձ_p;? QA/D0'Yړ/?~᫗{ Ze"s,g>DY&22}Ldne"s,g>DY&22}Ldne"s,g>DY&22}Ldne"s,g>DY&22}Ldne"s,g>DY&22}Ldne"s,g>DY&22}Ldne"s,g>>gy'ޞ|O|IFೃwyÛۏnϯNo~^^@Ց\^+WrίbwrA2|aӺYoQ +uK\#ڶWm=5]׶k/u骦ы&;Vᄋ/.ɺXG7/_{_L?=8ڿ']?Y`S^weX7߾]Ⱦޜ8{v~f}On۟s_o>x?_ɏW oqDt%aTWOdlvFO)r_;jζsy>tE\tE\tEtG>8CQvU$TfP^>h)rTtO-eE6kׯݓӗ/^^=z(`3sDz“ד_Od{}OX߾d3ܿsoYˋӛz~}~o7޽ش$^ܞ$|[GxylcmYKsO{v}q}֬P +?iz9=wBBgE϶ oa 0eƎTvvܓ(.۱UGxIG#u;;}#;~yѾKoN Mؚ[|\z(vzs~r}q*nmrFxC},w̟Nϟln1/N_gYXv;0%ygRxO5VrD]ޒ7% +<:xt^mkl~C';Df_e_t{>`p9: MT-;h{d{==OfQ[.u[h|iڃx>gi3ڌ&9k݇_#`\9Ev{QfO@t:8{u=*LTm_gWW.:;/$7.\.Hcu^V}Wi틢g'%3d*|5Θ߶w /m:0ӷ^oOׯl_G{i|Z-{HxZn/:٧ۇ67n9'?l?{0bp݋{ŏ?o}/ݞ;xv룋J,noӛ}Jlaq{LN/wq>6$5lf\9E;{a3KC7}%p8Pg~|x3[+I8sgѾJ{8Ö̜&ZΜ-3198͜=g/tWvO[1b;-Jg/8w dG,nVp%=ߴp}7(ϳз[з }WзoX u[H~B-Bݶnݜm=_W^p!z!ZQYZu_XqЇ/Z_|qq7%)1>4?Yjy 휖Z.r~HZ.K-;'Wm ]G;⎻-Nk5{^l2A[aREmٹ\DU):e~C}IeNф;g͜omgחϯ__i8Ns?{wޜ>\nvmm5:6=!s۟lc9f_339/GX?hk,7^;w+Yϋy?xLqh[};P˛"?ޭyip|ӗ/W"_mO&^_\\:Jysϱ3ri1Lx?=%`zOCw?Ű'E]*ẏ.ƜŘscbY91gF޳1'nԘ-;,Ɯy벋11EyhqI3L=fi7Ћnx_Xsef^A6ؾs,8{ym/N_F[XB陳]Iܹ烩nF9胩nRAUy[Vo0Ãj'<~vǥПu.0z' wC +[ݺP2tYOdӧ/ַ87';Q}s)7+ sٜϾ,J(qK޿Gζ^XYel/?އh|L~<C9==| y 7wImͳf+o'Pmݒz{\}HKK奥[n$7חۣ / ڵoϴ,ꭋ/䏝LwOlo/wp |R$oN=ZzK>Rp~׾Jo?z;qE;"ߠW[4>rHO&=-[Gt;˽`Ӿ5c֜׸wf7.;4'b6WMH Zq5Wca"g|?73,,ta {Y认`s3sF9fzi*9csْ7C_d߶E=Ӭ:ϵv's'fU6}ːmo#dΧG룰_ʣ.FtO o0ʃ 4~]Wz0EV?^\O 'z8f(܉ۛ;tU/殕/&7wjQi4};R-?87o.?h&}{l{|h.˫/5j^Bs +!TUՋw?LɛqsM0h&:܋dAb[3%y3]@`;7ia·ꛏVV@ yk*Ϟ^zq}Շ?A/!W~_Z=_Z{ԵƯ;:Ӯ.𭭻 9ɐCb|AuT*ԝV ꨑcj[5:&TMh.x+2UgSH'h 49tuhRӃCd"Q:R]д樶/Y_YtaW^UY#5|1Qݴ5U[G!T- U۴XXVC+4Uv c|0׹x, dw2mj:ョ>#Mͬ6k4GMMW2Um2(UFbe#S{A(K'/$ӪA֬vXyoE "*Gj2|I~7t]'밵`e4F6" %T^pmJ[jw F քZM4G7ƞYS +pdw\sh0Ҷk|md" ]'x.%(1m<6`&fiieۛP{:x\+L3ti4]=@D P0DdMr L! LJSg,Dd 8U`ePHnuy%˻(Gm&[5NV5rHOlme}jK_5yc7XTrT^Lqdqd 9X,E(6F=ʁp NNl}PTP_a@ %Ч@%QdR.ȩ {'S򮩭"\0mUu?qBXgHTGPP"Wx%BA8KRlde9ֆUsou | B"3-Z%t+!ؾ+ nC-)VN,Ԁ^rh2a9A-DseIz}= Q.k9„6Ls8r^9 L"22b&"uKf$%)g!)vd<,r[ V]aX +:WF_795#7 hM[YM%RB>75$HȺ a~‹"ЎRU5 *`zEA$vdyr_c _1wJ2ybK^,$0S5,@6DK+Ev2y?`peفOZ=yDt#Бl,{s'W5m+5Wi;rk.D Arڐ!{ȂWaZ3∬,k6E YʗD%!(G٣lFۇ6ab\뺮LuO> l Zox=Vscqi&?:"{~oB P -> +-wXAE3j4"f4dE|=2-T0%S.4BͷY)CՇqA:#FXPljx +G{N*<PEkyZk/\pQtȳEы -։r%d`,w\ +YWX퇴WGMjJ\c._ZhJ9"υ|4b His 5o(Ⱦa^ˠ+vub;,PB5r{9#j&W(M1 !=l]U J@x0i_ :Jv y!rQKi8:a5ʠBBMe=<KDAz;XlA}T9cpRQ?"{L1g_ ~0R2oخ99c}FӇ&yJe[m%D尢*6FQlX1flp)e!cS$2@D_ݵ\<.S/_+!kp1c1 +8}Xniʫ!ltfILFHpt&aAZV\q}8NtG$!㠏i`(xs0$W+Z՗=ް X.{ Dj DBhmNmm+һ*/nB(reetC j0_TluvH#}`TV0SuwN8<^r@x`[y?RA.QbX82xh Plш]ɜ.3^M q"z( !ΤfnÐ2O79{T!WrLruBw,t?`z).E[,T22B) 2"Ϩ+mKH!U}]v%^J[Cu I3\ogaW =ۈOB5*YuC4n&Ŗq8M byHk&9ys ʬAFC40V&g[]S]4>R*`xT,ڌtPsC?p&K1,4Q:4:;%6#ǚWR+m*±ȓczi]O&~8ؐU1ɻbL]¾L68QbC&:;e!mv1mhID8lHWe5eFG`E@@,fB`:XN3Rlf'E?Óhh1- yM\-ܰ630bA7 @Qƾ`hpܽFX&F6GU;Qԇ#zn@iC&z5n>U8^$*=y6&1^F`XQ@"xWpz+R=]/aV xrQ3GW<H~\>:h.li +~G#})o]2 !Lk5Nv.8P;:y|ui])ӈnziug&kԦ&Ea? endstream endobj 26 0 obj <>stream +HdM%ua[ hHhH?/ۍXd2"22߷o?O|ŭʜ[=V1/mbݶ_n ϶Z-,ֶ}xw mMr,z` +1%̲}5p =LScJC/S\VgV!e㽆x2[_/<dfK!zktF8G-Y?#ˈ)9T| +sީM(-YesCM 34}g͡$ \G*4ô#DZ͒[{4f&W4bfhm# Iͩx)60KYU=5|3b'V3(| ^k vt3F-2hlK:B,mD%ZK" r>$rAw2$qJb|)>>3.ʗz)Jm|%swjyܸ7g?u1v+1:;ovq,5fIVJ6v}6J_4kI4NehO^N:*uIijkLn̬ya`n:8:Cip8rC,2ij#ax92W6ug}.$ٓ,x ^:PS',Jv* 2%Ot=5XX%ZWP +M&Ffq$cĭqY8';ˉ&595GU\j1%kHAU^b' + AnyCӜx#G)q"*oܖyIT $!!^ځ]qxH֙\>k}WZ*#$ǂuK4V/u{atTXe"䦏s*؜Vi [_|VV Jcnq*5f@ъI'7*[dz9p\UU=))eeMtSߞM|AɡNMwsNvo%d܌n![ +w/![5: +@suT]v]=JsZafPVOɊ^CՋIO +npfeTp}b)n'^wV9Py,m/El&E:?AI > +sys:QkY.^ +Bq:!ir3L+jOw7o5nQw+ى7HsC'7e:>`uS ;= j +={O~غ{_r3t/c9S?Eeuw'As (Oi#o1b|ҖzeYߛL.5JXx3ͥ]s)UV iTL'+2aD2d +.7: +wK÷USMo1bՋM4y$9sՆ nH䞣2lHPߕ${y9UguGnB[ud]8(t=["wJj%jdw;*N \(z>|t(ǻކnW-$;˪Glw%ySJWc{?}F] endstream endobj 27 0 obj <>stream +8;W:kM\3i\#Xd:cqp3,?ai,>e#TU=aeg^>E$V[QmLC>'u(CZNe<9-R-=Ke@8n+oPe +EK,0>]31`]'#.#9(+?TEbUCt=?&Vsco#_oG;2M"]$HshMnK)9H2JO`_N?g/c*Tri1 +M'J\=l]'V>('IXP7,^.^'fu"Ha/O1b_J'W[o^C)dF_Q0FF*N)f +X.j[6Z=H]_rI1aok678&_k-kAf-fpU1,\-K=3m3pmcX5lX4DI_KY75!GAf!:RHI7c +]+-m(?U>![?!jLYmsa[S>E_saSdLB^&nY.sBqEWn4P'k&4Fb`(ne>+cK3H\`0Tu+T +m:.'>D\bJ9\;eYJg\-[.<.>b2!+;Wfb5~> endstream endobj 24 0 obj <>stream +HdI%GDqU5+A -tDC$3cDtH~a?߷_P˖Bi{c+ #SG*}K%쭧}|駟77=fqϡQƴ1z=H}ldc1MŰ28=W3=uia7 }Hyoq=E~,-nw-{mcbkJ+y/,y̳=b,<&G&QLz[P<FcJ.hYѮQY UAju+Ŀ{Dٗp),sU茽g2G=mf@|!^*W{l^BpV;*Irݸ`NXrYP$DHDgל{qZɏ_ st_,,Z3.%'LaӡT'azT +X +AUܫ؟ W@_393p Z0q+ r`N _1sҩjHkY6p-S.?O|ײg+^x7R[;dB7f7j&6mINSkF؛]$IF)?Mj80}әdEx|,qd,8)җa"SKhJ0ɷ(ZYMGX9g{cfuv= +C^!äuҥn|\$,UZa x+<@1!@Hކ + r3g$Qc{J@3PP%oP^z؝^`%ZB-%;*+] cF?JweOO|H$%q qiVqVeOBɗE֯\(|*lURd[ ?`lA#MTۓ<) (RѺ%a,Iba4K9,e\lrth8MXZ,7:IM$8\lF Vy5b}n7t?-\.q,*֓.|-Vb'npeCS\*1%nr%(2H oO\DE tj<9W,.0ک2dMNBӥf(.&୶$$ {>?XorӢOQ׵ϱ§Hw|ixp<>r'@` GwKN{i:,U'@Yr"oN`zP r +>eĩYCշP|M| 3ih&15bG w寢F2 ͳ2JJ.YDXw^Y:Zt<f& +CUHғMCڜ#$"83xaŵ30-n\ՠ -k@b`)r5,VƃY&}u[)7XHV*ԀQ +l(t(T *|%~|icJÎUʘ>mP &@Z]+-M%ًwRo_LeS.-=?TYָRo鑜0Isߕ$p» < Z#/LWj1Vj2p.#X4y'kH#5-*P'\cUGQ)#iY&N[I< z]Uc*x1~g JQ욓MBǜr5O4q +eH1h#W1@*5Ja~d +jX(G]'6+ B>i +4U5nTt#׻hjʵBtkTN]gHPJ3$k}, 5'fçv)ش)@UٴYP4~vS;v0&87(m1x.7}0' +/GfGar\L%> +UcFpe=Y<I}qMY#C|,iQj)~K,֙P4Lp]9V^4 Pܺj]B>Ud`a0#"fȊZTxvKY(@Z'|'W bJbnlp;™p'iW8,8a¥N |}Z7d<-{HgkșA Xӏ Xj IPl=S']AĹTEBu6FӴYLȎWd\M, pK45&ŚZJ8dvMA?64͓iqZ<-Kb4^x>kG<ӳ$DŽ0뉐iln&V#Km#tE5hTX,cS۹ +ױ*\Z׺*$\c3 )8*zJZGFnP6jLh(ܻu%PI ;+ĥյc5@hKd>4%׿d9v; Dsa=?q ?u AVd?M)G>r)U_Y1RCDJ)aiqU<"Cp0I-{MN<ё_6氤˒lc65-zb=6o`o44ޅ X"H0"bhk $Z!5Ȳ!<љ*/,^WU>@.pRT,oOX_!{ܚ\򱯡l0}}nL7u<,-pᯝ +ή0O-qF]#Qk7f + %=Ca ]@Q~// +nrGeтc|X3tJ>B%Wia*2@F8/z/r15-ӷO]<+bs6]IhPY'Hjv h^x/byz/,0߲,xU`,'Snґ߃`$4$K_YQo[ I^/QjP-ڒH#cj&nvXJMnm%p0 +COSU_8b` X v)S6yb؏w ?3uwȑuOJ*t""Сigc p +&k /UT,|DÓe^6f橉=VH& 5WeeRbg"Y[|[H0j$aw`"A9˜OB{jUl]pNŗ&95Ot (n1b@0>Ue8fN}rTi>2F<.VD,<5L*l=$9\Sh=kaa11[zuőrg/z*lC5/gKȑB]xa0$nu+ +Wi)ܑr&-󽢜Jy6UZ_}A"'uy=]Xޘb[F\M5qq^,cc%[^e d^W t^1& G1YgX}4>ggZ%üf9_&3iCm((M8VG` +%I#, l8GpIB.Ej7 +R>ER$.0L!1"D-NjJɰ` 4FG1LWOU3j5h2PbPU=(!x^ÎABx-ױ{R)pemOiVGҧMO.z 꺺4@i, +aK.Q\\95;W>Ǣ&k +ŗ| hݎͤrK.SA 04}xfaiϧ{$TUS3泊 'XU۴-`x&R(T4lZ"F0aЪf2KXS DX<@6_<<ƕ@x5u~ +T'xtЄ̔a$QPd(< @ZbK'0Ħ"OsoXbg`,.5'u2ǎ\IUp|) }+ wsVY0?wEmMdnQuuܰ(ɦv-ǹg7A.;JlL4eR<Мx6-Zm-Tejn֯,x"g8'0[{|{4.4^sl)&f33tt>xh~,=M][9]GE +g}MW Kgb +qJ})6_rgc + gɺu"?oJDEMu~M8 NZ1N+_ 9FJcK+|:œ(}}9s6o>Α߯At]>8UMݥyHv  ?7W$y<#*=6=5 + ~aSo2|h\=ⶮRX+'}\k +p_tjU]o\fGF or9qWf40kD<{ƯiBK+V YFiwa>,w8L]g0Ӵ]icx˰]ſ~7XX1M>Fx0%=N1y@$R,BY}UrFLj & j8R?3$U K4=44cv͉xmI1Hޙ*Xz4K$*pw@VMjM(vP|r0]o>+2y s#Ǫ11nS9/~'E<\! +;bgP<ڴ FwH`uˆwnRdܡQo Б4Ú]q4 xy\E5BH+AO0A]yS6l4ʆ>9/+|Cxеp8Wƨ1(F_MF4IIvߦFsN*FmG&̴h "\nц69><@F86'*'nB'ClVv"Ҁw%0ѐ1]FC=;† Tv`q;Ls,N}jk +j@?7oRXbBxٔxhb~\kLTY!hҌbX͵cfw El|ȇ`!} 0V{ =0N0rƿylRU#֝qǡw@U) [viS6V*E}jfIpuRa +aWǶ3, QB7!w> +|C6g2M)A򟶙Dw?;Ha1pk64Lܢ A?3;F(Jk[f(hljol3"t.*xugsQ+}ni=M;3&N3dҮ<+=KLŷRFiCs!c4 +=̰qyN%HW>QCܜВ"HL;gr1HJsNklQb3IPOYarPFG[}$u?oau']Y#JÞZfwaЙ^'ba1]Gsl\U;{zo+{vkB}tfP)?2y"UcÒ>^%wt뷶4m(s}_+)ߡ^n))lO-nȜil<2_'T9+Y 9ưA F/RXu @=*Tس@UHPJ&T]?+sD^+4WrMiL3RDI\!:o|"YPC0.2&g>p&` +4>]k:TF^J| >Pb%e l*0y PMLKc3[:J6n,̴2–m2]!skc coʊBM!DЦfsG fB/y 2[ޕ>+y0{i+R6n]i<]AsoI}[.tOiZٲ2z,i\pK9[\|:e8s/GA_/NV39cG:TݘB)NC"f}D汽^ܪ^ں36zk_O@JdzjgߍB+999cSц^e܇}V s(H : WH=v +_en"P08*o vhUldoAbyWgi.:_Vԏc&:ar02ד0-f.yH˦*M`MF>aA|*'QWGN׃F(^U-d}Oq ΫdԜ^ nd~yo1o0B.6 :TZ3]4IR#IMgUQE0AlTtWc ZfZ GV^;0fqfNAOE:oA @QdaR2 RM>k7 +rU>VnF:{,BH޺ +|tD8Sak2ch7ɩ=lN/WCe޴jTL)A]Jݔ0q+!B܅wz(Ķ%e 0HJ} u6qfeOB,7D^)aFZE1HR$ ?%Ɋ&`jOn֠JVHin QT顠?ؽ,V*`VL'ޅliz=a~(qQbZ%þq +r ėB!XhsE'E23.9,>"r`lU(P/tõlef0ؒOá[sNjM8MpŔ۴NףySI/T2ɇ)$8G\mJev% kʈIQs` +W Ygޏ&S欄-5P5B!groӤ1QoKf/Y&K6%ݵgS v1SP^Mro«ꋵ JYVTH;g\+7mϮE[:2iK6k[AHKN.ҢߔU+.-/fY.Lz)ha̘79)\{Y>E^ P_pVG+BfdѴV^]lпn#Y-/I&3F./ŗo)ƥ{S~3G +a>mq:<2F]-H>vxUyVC)YDuI0MXѬ/ؽ^ILSIBG)$UiG!u B85^>,v>o7mt%Rf/exP&/0[nc{|'3n1 QHYLr +!(//O "/i,&f" +w-X>;ܷM-qbu %SJc+_ No݃w.ܝƄIn%*XA(1Ǫl]~s4F2*n(S>#Ҩ',CѭX z*4c>}90ӏ J(vr$rU_|ǟ\ endstream endobj 25 0 obj <>stream +8;Y!EM\3f[$q%Eup7K=i5T3k4RtTW!D0jQlTD^=;R!D'*B,V\=LB@/7O['bUqZCu4 +KBqAZLr+;;3s/&!C"9k?_tXsn.!1]78mNi(UL.)4j:*l#aYMn`C9c)r+@Ju'9I'9i +DVbnr5JC#9i]QL2,3F31=ddJJS&GZ@M`S)?Q.i=jeB%2%eUsGfIIAcc55O'LM`'j: +do?]E<_eGJ6j=8rh)[>GjK]e)jHU]T73Ud`Dm!"Z4tuNP&NPka/Ld%:iVA_=cU?,\ +oO`?ljN$W96kRRt;!2Y(@X4D_?g4poXt"ahYu[j:,A-A;=C!9ZDHs^2RCuZs9t8Hj +aul9'Q)6PtgGEkjS,\oB]'s.+j@dZr>;Q61OrnJ^a[uL>[Ft/t6q=3IT!&.p@O3S;~> endstream endobj 22 0 obj <>stream +Hd͎e ) \m= 2 <@c:`ՑHVH}?o?_q+i2s},ĶfM?F_oRk>~ӏ;6arYL!,]C.mcH}lfc)4f12ZȵCw!ec^COp7"0#gZ2E_bdl)$+>4;W~_+QKh֏yA{Jsn͡jXZH34:gכFCVp=D qLI~0tjZ4U3叢n)/e^Ԩl殘6 6چCLRog0I6NcPӌ/ +_Ģ% sb%W5 28!>`;a7eE-1+b(aNǻЪ]h*X/+9N>/+Wڧ`Lؔc +d'cq,K0[!hcV?FN.hEX7]+wNo bAֽ̞l"_iZv94 WjkܙLRD9K2jbüz0PJOWKe&y<^JhWGXn(5 +IIi*,)<悕UF61C3k:A 錅!\~(.-;D x+i]< +r,E!QI9}3I8 [륅}1k)!8K.DJ>Tr}TA~ `W]-mgdr$ zbmB\=U$OBɘ=/_yRw([Lʝ; 5r(J3I-Мz "JHJZWPANlN+iKk.XqLK"]h&WK&jfA=8Fp(u>` 9VYe|\ .vCi7+h=wV]DK[wX)u;{DqqF%^{ߋuiXcu$GK6L^\a`NWMʽ(t6)˒*#Qc.p,2f?('ɟ\,o7B sh"ՇإepߞL'}VINN w{Nvod܌o!A[Jv/A[5=xBʝ׮uex=iӪ3kk_=X@q,I٥-^ &dz +=T,%pGs) +u7KQH}{='0P{:AhZ\f+`]'0j*˅QJm=az C/d8V E>ճbFݥdGjݸu& + fu<9v]T5![ SJ=B{O{cr4צcйSAD!e5w'B0= +S9/K&NɧvRΥKk$?Qt$~hh2K(n?{4о>@Jjf(NN$;T!"'̪f>%: D,QdAtƴKoUʙn;G@jr-woݾs Mz+g<  컲~/=7=Gj鬌<2\:X=*΋YF!gDf+w\$Ί>$ZٝRqGM gؠj&籠gf嘉jQ2J@R"A륤r5Ox?/ endstream endobj 23 0 obj <>stream +8;W:jgD,>p$q$BK=,&0aH(F(h/6P^T-O0o"Y%>Y@afR\edrS4?DVWU50NHG,>A, +!WC1incI]gT'_I=jj586c\q.Jc@(bQX('I:^^;+[7TATF+),;=?JC`p;jBoF$Dc3O +,A-s2;N*;EDI0%DT(]A]F*0\m4FtrA8[jr']7_AY#P"cJ:.Q=3c*CQ0s+(.VnLD8F>f8kbZYM<_Kr_ +CQSO26 endstream endobj 20 0 obj <>stream +HdK$ Dy@JB !̦G3e`ziF#˿~~a^VjC-[ +}}Z?^a/3n};lc'-TJ[OiK?zc}c%`L{`l?^6=io-űj%)GT{{o"}ؼƗe 8YҞz YZ,?{ԲOCj.o)yk kީ1,mOe{Kn' =#b\LIm8-:8xb]Haju+@WP٘)0sUT~d NsV[hfPjK[s-h=tJP8"_X+K0 M2Dz'KC&p(;e#Z9,|dΙ>ERli_ޏ'if§HSxʗzGNk ~+$oVR ~ $'l7bW@l7"ccTic|Z:yBYv { z-pElgc[r2+CKŰԶ%5N:-8<*ѷoE;XXHt%ueXi귦 &)Ebl.dyUH١x5kNM܇X +a\r7,-' o >,a`@"oJ0ȱtX!NfKfn$nGc-઄]RbCe8ԐXǒO1u}a%`8aBljj3_p9QmB`Α.H#[;9sF;W**Oq ^NJ&aЉړ4ѩu H +4u%K DBM5sZ,!KBs,L˔^}3yUV ~ rיhkwQ +(XThr%U\>HrDrή\`.K$ `;pJ +uq0<{JP'Sh-}ЖK*o@UAu+~0>gh(|Vϵ‡17B.y޳_H\O嚥~?|~+%"iG* Yi4ku fHR_Emژ5zg%H5Jٓ%B߫ctGݥBPѢ|)W<5GU*B͙Sz9WJ=B%w~EWi+AD逸_>VN #LB b t}%4UWܸteS&254`9ڒgJgeJ N%cLk͙:;\JUgh0gQtR9"yVNjRn;*.OY >eMPwXӣ&B@B=)"=횔T =oqJNTkmd+\U)6#y@v+5ru`sMwTpZ!xt#}w}{EeT98B.'`n[6vMf`6HRV*K0t _݄M>z6c2cY,j+dmL3t)x">/z4)A& !BsM:ޒ۠{g +ދ$Qk鸦xn'߿&IG ?۝C0+IیB4K}9PPhҞ.Q4]K]tik206d (rGp»JYQ?µNbA)6 (&d;g@g)vg!$WPzPs燶2w_=41*nNY -rJw 'qGyjV:QJ:WUWgL !]oq )< +vT9i U#-A^Y[5H]JةxGP`R9t{?4W?eќ{sڛ(Q0~fH%?P Hg6=ޱ[NyjwEDpJ IHE̩[u?vRNOϴk9purL'7;&r!6>/%Gٔ +T{ YRDL %&;, {l=r*ʁpQI4}MNH0%پc(p52n Gء/qJ8!X{ {8 '$NW@fE!JW@sƦW,?NK,Ok`0V~"b|itjvnV%zT5Q +S-5fLnera+dfh10ho3%$A]gEm^P8 d#P.Gyp+Ҧ ?gl,ک <TU HeT>)l;nWD[L '}>H|@ˆf3F}e{{*ȧP8t|;0̛N_f|e[Q˧ڱ`PT1,[VaHMO]<+be)fjIhLk v 1/{{1~xvNt܊e0$Tu{ݢґ߭;`,4$KlwDIV1 ׵JO!?y"!LTk&Gjf,ek>`.VD +B5Ʌ;§jlo/ R2ehI'69|lE~fxp*(~d +t^:Lz6z +\A@&¦Pk!NgyE3OL TD}jLHǶFo0 +j1*uIq,wr90/g1㙲-{DuަNPQ}ZO5^Ŵ[Rz'E`@}D[혙ptr4_y +Gin>ƌ# ~ l5L*lAs' ~}jnqX@ ^l#![|DxB_QkCIw`sg9،8i Lj5#uhP\9E1ޏ.h4ת=ȑFG5F nJS8Zl +[Ȟ[ +l€wC}&S鏈}F߱LxL.Om۳cvMfmj97ӊ%C)c7ϗDZD(B5̅Hp}l| ) NY(gpܼF\A'&l0 3=>p!d©heiYgV4S +E(JG -U}TAhUR@U&!¾TB>jZVx&n7&'ߧ8TZ߾bt}tG=sIڥ95Oˉ/({"Ն5h6"(If-W5D^tHD0.ݼyZU`R̂ 7.S1T̺4}eExˀWˢѣUpdUY+-lШհz@a7YND ;2١6͒0 5ZqAT & e8 i]S+GS5AG1slZ>J͸M$A%d[N3/D_].=f[ +*I\{px<M2h +E;HUgc v /ԖQ}JrAvi ߬Hhs؝ ?1$ 4 seMD?\wLܢNbFYeFvݙy y(/MO)1T.e/2c|t- =4Wy [>քMx'2yrn$c>3]5ߑ{׿*U )]i}]U= cg,g2'C =Ea^L@ GR#l){#^Wq𖾤ҕqHSDd/}a7\R,fwIle/l]yJ6u>uLE $ 4g|a+a-R_9Mγz,?9c}S +Ԡ!q1|Ў5ȷ(okh%ucK)ɱFeԌ8L.h G#M./9f/mjCj}p6bp:klO+MU=5CxՂUrխKhifTA5Z|^J۸uSJe6䆂6k\܈nU%{3͗fƺ.ͦ4]`s.u +Hг8 +*t2q$rG͂BJ5rtPK;J?O21u%l,b G&[$W7GS[ic,D#PgwSr@j֜ot!fU^&Z"ePM̷VuŲV(at`?N8L T2 :qҍc]c|qYXx;nO'l #ďB;_y67ZVD$Ӡ0 b+L~} +2>Q7W8JU]XHqGbyZ!o`,3JEIbq"6xꐖ8_p3 < ck%]{ASNZX;-Ι,%dkюh#p[6c/=I:}MLA f_mrj`ߟӈrHK#uY{hisƓxE|:(geg8.ZFRXpAn]Fyn +.͋bp78CDثȑg +9['1 䔄^ wyٞ΢9׺^:J4Kak#}iR].3eHj(%ZHS hrcs ^"j9GfsZDtTA"C9s<ګ޾zؚǗN61i`u*}`7X3M.C#L= <xwmT6-gĝ>re~1gl틖, %_EeSzלiKBdխkh;m]bP$Ayp9pAʹ X;BSQXqNk; .{K'? F# 56Ʊj?N]twjCy:7c}!5r b'ք/ͿyʵS+Tq#v5hVx@[oN=Q2wXK$3IMSD{$ >e侳J~L=0nR{֐Ȕ=6_O5Z͟JѠBMRQGzvkLwj)bfm(WD ō qjOv',7sƅHG&ڪbJZBү]BpktҶH:m8rE` +Bo^Ԇ}[7.CK@c6*JIl/ {u2_#6g ݛRv;@?7fAqlhڵ)>ȭf+ؔ5ɰ zy5w}iN_%/jОDu~YG.Z@kİW.mU5-v妛dWڮCt=dk + G~?#Zbq QT2I{Ċ_+NY *Pbݼy B3iH،UHfH~P8ۙf~dFDi529sz=-GH `#W(1.ZB2 +]1XPKdX6b~V1pŌ)Z]-o]NxZUn+AL Lg᳂7oex{ƴ?`o+HVXQ`d!8V  d]R:w"Xak1$ro< 79_$jP^KAJ9ZV:0]q$}he@4 j_Bal}p-3{H+WVQZ:;/) -YwPp)8w%)CIi ztcNX %¬ %b.t/K0:G hfȓ| +r6|[zֈ2˘aܥJ^KOku=wHO:WtRÍv|Dɢt$UB6 GHB.&-vk +b{7 CHRϭ 8wxh/t8*x o|t- +dZ+/|]7>)yv7@,5|+iklhؑ 3C+`ZY:1g:oE6R6&Fp;A.,^֏;ݥ`h|׹tR0d%UQAkIS35!<NgVs*|X >stream +8;Y!D=V?B1$q)bl(Pa`cbB4#qFp,3f7L.*L_@em_OkEncr_I=Z[e:5%S`G(Rd88?c +IG'bI&RDS$)MCX!mBg2A"H8KYRIru.=5'4l:^*Y4aRpuXa4_J&HLDmn;A5hFC"M@7 +CI^[FK[p?rp5b3N^=dh34c=s:Gs,bF2IC5`Ahpa)@/j"%ofd9Xn`$`g)YB>#UXS[[.On1_2 +D6<'C#5MA= endstream endobj 18 0 obj <>stream +Hd͎e ) \m= 2 <@c:`(VH}?o?_q+i2s},/^߸O¬m)`K?xcho&{Zȱh(bJ>̲}56a& K7)ck!WC]bH٘c*lúЛk=Re('-dk>ZivVG-Y?+ˊ۔jgߚCjXZH-̗OfhL䈳b!+d ii+ m%%N<^5c )L@8V1S`fUʏ4gŴY6͠돗܏y#h)ʭg0"Inx8+Nb7PE[qXU,$/ڽ3!zJ+bN)BNtwU҈UA;._Vp=}Ji[ ҢLa>-L7Nk }+6YiU XPq}}Yglbb@Wst.Ȇ;JƂ؍aX;8wugp*ۅ1xWqtUjkޙ㒛LVDb :8yva$5 Mx< +8 +I.PLb}T"^&Vǯy+sJlb$fgtAө@8AЮl^j#Yp9E6d!0+Ξ+\T!̞E֯D<dJ(u M$R}Os <H+֕+TjS'I?cx1{um%+.iiijrTh"7MV2;!&dSZბl2 I\'r=W)qYW]DCt <6hWŊMeHpc# E\5u&/yo*^vH7%PP\(z$(ÄEtU%9ZJgҾ,UV rUskW1 +V4Gy?IB录Pgszc*J/$+tvM$Ptl"q0}߁WҨI.Tx>Q .=xKk|.5?4[i|%44i \-ubOvW*$ʬryVTG)Ktmow髶$t/y +U$rTELɩT}МMHLȫ+K<#EFjua=vqFEF2aĹ(Tw 'tj3bYU9f6Yݤ~Fm+}'bq>,.K +%ʪOz%('TƓOlRC;RܪZ ] 7QV?*;`bzc@9{Jʳq#Aa~W~w_gis$'j2V +">stream +8;Xp,I2%Wl;(r>H_'g-(UL:;.J,fZOFNR>A~> endstream endobj 14 0 obj <>stream +HdM$ u@Hc+ /|13s_WΗ,D1A_xV7Kc>-ߟ~ǿ_?xM[Ok>̶796=Vy/QFkXuzԾ1ܗ#&[N {粗v+imtG/JWb+{|Y'˱ųn3d huyj.nY, mey[sYn-—OqeA!r{l(24EEZƽ N+{Za.0}ͶU T6&` +G@sYӜl{}JݱLЬƭמ^RW;NŊ-ETqbXʀ[OʐKޅ|E>T;WFsJqLw{{{7A:*$e' ׼1Y[cIedAT2#s\3(ƼjW%@`A]H5Nd؊]QoϷM"oE‘Jg@ay`zZ:w}lu1=^85He*BRyM!+R*}7o&)77OadӵػИQE0\Ir%=\KU JKkd,VVǯ{ܔ%8~ +r64y)MާtXr:J7]={O +ԪXT}p +C8ފp&!)2M3eR4vw( r#[ GP+DPBxBq!ε:[=t0v .h2ڍM#[ٸ3[P,{9rU_!zJNQiNS`M ֈ?DZU\$2 ,vW%r}0էo&_UT~EF<(U&,h~tJ޿p{\U +]d.K$O8]x%Hq=\Be<u ;? fjZ?x+'qA<{:KXSN7ʓâҜm#"zXIZ*TngCG=[f呷/<0ʦUAvJ6=n'*[RINޒB,TI.4`3 }/\0(̵F}eqɔ8fAamVNdȚbF+f*Vv "Ԓyp(4ޜ}ϹƧ'>4xp| +~Rʟ#O8zxkr45>iZud[F^5ڻ !%W$SVmޥak=4f$WT3-I!OG f%ubg*N7$;1<+CT|D|km_#j }U'>`N !WA{IUE\jNmiݩiȫ!q:s]X.Ѡ͉ EiG!6S ֈB/gI\Wuq4h \+FNq>,J +%+UGƀJjJP(ʼ$ @HVF Q:蘦KqkjiSMpB >E.hkJTp m0njwb"#ZS31yԪ~64}$(lw'Idzgv^kEݙ`PW(af5_Gx hpim=KE \Szg}y +D:kbz~ʞ6C'ķԳTib ?9a.ŤX\KQ,cl((xZ.Ģ&ϼC݇EIT i9׍i򀢌].V#+cPx p А +Fvmf .?<Z3 /Xf4C\0U<%mPJHYEH. [wCBNu~ .g̱$9r )9DV'Cg$z1˧"5Ahcݚ\rK`^&fknnx$9Y04=|kaZᷝ +ry_σ+Q`UÍ'vq?F7,$d6v+j/rj uya{ lwyr9iƐܻ Mh  arhS:Pm0ִv < QJ,+$&SҠ-31{Q=սf^ Ued$.ImNZ`sඍ*pA"IfT!3ы +yVn`NUT>Wɱ` t%  S56 +`*V:|9SL0{M+ WśZ &-93Lc%{3%\gBqRw<,dtlqԇޜD$$+ڦRg0یE8~]\A8a{PӶgL YV۾y-F +׺A:X,:)CׯuP0xb[o(wBlO#z(^}ZsH|e35w Xni+N + uD5#7>@R(!R#F iPgU r.#P<:Ш++[Sfy7ȢqDI󷐼ǗDYy2DsLmؚO$՚hg9 w%ix9 /)kؾɣF`+vGΦʹU `102GZCAA"wu]Q `y $'[5\#0=_ZM$ʹI8nFT&&LI08ƎxEz*c+ +X_XQ3TA)jFYUP>uK<A$"v s->m*OT_Z7ZL>Qrs!^ȿk2Ysr}FXWl51B"P"NaUZu N.KXb˩_Qv A l(Gf]:=8E>cujW?* 41>MM2Y7hْc$4AcE^QeȪVZx1M{5^;Pa5ȴi9D +oáUecLKxATۜpC$@nZ*P9sc9nGy<T(!ڲuJva>WI:mmG?#݊5<QU;Aَ)Ss‹6F䵌|*s"BT9AviOG f,dpL>Y DŽaPj˭}kuRp +ΪO-ڽJΤy9KsD&RO-ڑ(V?>qQ,j)3=X0yorI|'ݻ]{ڧW>wK~&Y`uK%ƛcc4Tdڭc {<{MR#l{#]W¤^Drl8hvت]~ycJhT<%ב"ֱSU%̱ǚMRI~v\ h>&4ZFTd%vدW퀼īPHk5ަEc;ȷ 4ydRk$X]9H()1 % +W{)2ϧَj6O=$F2T]nO}eH|mSoR[&j"%&n[[gMGjOsO^Plb2A$Rs VNSדnLVMM5~~l  *!mv\>hb,(a)#H;1r A0y~6.qν +ouĞZOM"&)Z <&QxxϳDžx/=PI|,HSOpӹ)n1̽wh:<͹IZH+FׁD }2KhGvF<g@Lǁ -l"J#'^iatWɹr?cNXzmszJ&PLLk7t\ܩ7Gk.4R;1~61t0WDLKGs)PXc.Ԋi|cLTtv Spŵ^Ha{(}|ɪ=80BfgIf`0=# `cn"V8J*}KołRQ=3׀/cKP{7jto{x}_BlcF$̭~쾘,Y 1F`Y>sa)tl:[Joo}}s =#"z&uTay~J0Dn |/ZpW!Kr#j=A9J,j wpH=]E !2(cT,-ך;YYr(q7z&@;y}%B=k}Rp(V51C3f'07q̳?ɌAw; ]ef5=JS7Vy70/<(uA#BXggě"mj8N㐐g}մD/{՝$[jF-H7E]*؅vNJ 9Ǻx ^lx8d(dL X':C c>H~XW}Bo.3>r |I f5uq +!NlbU;L|BHђjaFj8;a/<ѹV0yzeZT3ɸ6j4o5Pd`&'sQ( >Z=|#pwGʱll/xM4kBJ +ߐN6#_WlaO6z0dG"%pSR;EKřg`)Ber8waE܎qӨ;aID#` @c;;Θ0J5X0SGšl#!ٞ׉A]y*[PLjyLs`x/-jK5K$Zkx@gbxo5K+Jր8KtSBMm8/C0uI?_^NWXe~0p] +aI؀()_Tc^k3f tWګ_J4 +BԲ^;n4mŇQF|[0O +bT+$%iœጯgloP!C;gG(x,;?VzRSbzG$Bk>mJAZ2*c89: +1ܩFn?L9;~`˶[Xdžx *W?Uю=V`7{v[9ٷA6zT /\iM#}xh{U9NaJ)ssO +wux7iET%n`xd}P qƊ$v2,[(g B 7e)Ҭ۽x)&%Q-' 5 bZ>"wa_{)FÚD9bؼGUjR~"`6:9؋_Cj^"w;VG5Ċ|&%BW"~VjUKݝhGG8Q`&( Y?75CZ WWIN;/R`X%+XLD13$U͂*|ЮtWuDx%]}["!`,_XJ>~V~+O~1[cO2jsŒ;|_KPY Y7[( GF.2gyPꞜҎl5zl62FY0t.$;kj!qÓ)]r@el9+s= Je9/F~|oErg{V/E ZrܒҶ]-c̱=8θ+SZzLtP)TޡlD +#OXhD1sG,j^i{{`6֕p8%Ig߿!!:t,hMލ B'B@!qs/cSѦ\u>+:vmIҏA)N!l~.Amr ЊuB)QnI̭<ĽW._xXUv❾5Acgo.{Z:u;GC}W3+dM.~"=pXiRqPE5O@F\c|Ϭ1#^ WY͕t,/~V=]7Vvԫtp'pi{fƟfuF,dkk~s0u(R" 崕ʢ:pAy| ]*'ܑ4)Ё*GS@op$Fcj=P5LpTI㷱=QI=঴ߓx=s®Z%נ"\0<31hs%˹DcP|Eջ}R×[vR9B]/zYcɤ,앴i'RY^>EAd3YzQlc]j14U `-]Ț㺱fDWs#;ѯ`QVCQbo偱t9e};y1^ eLjx#[n&& khP}ɍ[gv%!Fޓ[h"\LZetA)_I]IrUnTs`B-^AO|r*uU꓂ +ju(-2T_̪GfzX\}PC 3=*V8ya |Plr3ya j*B1R'EtC!}v(݂f0'T}Uh:f6FQ4#}$uw.%eUR,;[3ܳ1K)+!EZ{sbN1fX&shޅji&L%V,7!(r\ 5bL2e8nwlRlHz=ޢ}ҹ"{<ְK#'N#%:S#'#CN@ݡC6)["GqEOP,JJʦm% > ,JsKO~Y2AQ\S&Y{̤t,ɂGg P?嚨~!湒q~t~TAa|~i z^ejZ_ZwWTPϸVĽw-RI\2guۼm9&͢-Y]E!FVܜ mou1zf? x`•M/UAvB%,9{8=0yQB-h/\$ ޸-70pypm`PZs޵7. &k!Cהnd8B,4]O9o&SK&Oɗyk/p46'78W u_{Yn|NفćR] :؎O7[ PW&|h5FlFa6B0[y_Llgxz4_,/w=KrbhvN +rК؉'B!'r\0(K.oڴ}-V}zK'Sv +:*hw!J1$̩|aC_L@BqKteWaI߿p ^ endstream endobj 17 0 obj <>stream +8;Z]L0`_7S!5bE.$!5._?9eMkn2C@P!##eA!r~> endstream endobj 12 0 obj [11 0 R] endobj 52 0 obj <> endobj xref +0 53 +0000000000 65535 f +0000000016 00000 n +0000000147 00000 n +0000015336 00000 n +0000000000 00000 f +0000015425 00000 n +0000015803 00000 n +0000016177 00000 n +0000016555 00000 n +0000016929 00000 n +0000017307 00000 n +0000023367 00000 n +0000710627 00000 n +0000017682 00000 n +0000699897 00000 n +0000023666 00000 n +0000023553 00000 n +0000710447 00000 n +0000697578 00000 n +0000699720 00000 n +0000686442 00000 n +0000697003 00000 n +0000683766 00000 n +0000685910 00000 n +0000672680 00000 n +0000683227 00000 n +0000670029 00000 n +0000672175 00000 n +0000018080 00000 n +0000022715 00000 n +0000020721 00000 n +0000022154 00000 n +0000022202 00000 n +0000022861 00000 n +0000023003 00000 n +0000023123 00000 n +0000023247 00000 n +0000023437 00000 n +0000023468 00000 n +0000023740 00000 n +0000024148 00000 n +0000025695 00000 n +0000028934 00000 n +0000094522 00000 n +0000145325 00000 n +0000210913 00000 n +0000276501 00000 n +0000342089 00000 n +0000407677 00000 n +0000473265 00000 n +0000538853 00000 n +0000604441 00000 n +0000710652 00000 n +trailer <<9B0616C908B34AD39F15C3556859163D>]>> startxref 710872 %%EOF \ No newline at end of file diff --git a/microsite/static/logo_assets/pdf/Individual/01_Logo_White.pdf b/microsite/static/logo_assets/pdf/Individual/01_Logo_White.pdf new file mode 100644 index 0000000000..6ebc9fa52c Binary files /dev/null and b/microsite/static/logo_assets/pdf/Individual/01_Logo_White.pdf differ diff --git a/microsite/static/logo_assets/pdf/Individual/02_Icon_White.pdf b/microsite/static/logo_assets/pdf/Individual/02_Icon_White.pdf new file mode 100644 index 0000000000..ce1618903c Binary files /dev/null and b/microsite/static/logo_assets/pdf/Individual/02_Icon_White.pdf differ diff --git a/microsite/static/logo_assets/pdf/Individual/03_Logo_Teal.pdf b/microsite/static/logo_assets/pdf/Individual/03_Logo_Teal.pdf new file mode 100644 index 0000000000..d12f5fe99e Binary files /dev/null and b/microsite/static/logo_assets/pdf/Individual/03_Logo_Teal.pdf differ diff --git a/microsite/static/logo_assets/pdf/Individual/04_Icon_Teal.pdf b/microsite/static/logo_assets/pdf/Individual/04_Icon_Teal.pdf new file mode 100644 index 0000000000..125d287602 Binary files /dev/null and b/microsite/static/logo_assets/pdf/Individual/04_Icon_Teal.pdf differ diff --git a/microsite/static/logo_assets/pdf/Individual/05_Logo_Black.pdf b/microsite/static/logo_assets/pdf/Individual/05_Logo_Black.pdf new file mode 100644 index 0000000000..be9a572b37 Binary files /dev/null and b/microsite/static/logo_assets/pdf/Individual/05_Logo_Black.pdf differ diff --git a/microsite/static/logo_assets/pdf/Individual/06_Icon_Black.pdf b/microsite/static/logo_assets/pdf/Individual/06_Icon_Black.pdf new file mode 100644 index 0000000000..330754384f Binary files /dev/null and b/microsite/static/logo_assets/pdf/Individual/06_Icon_Black.pdf differ diff --git a/microsite/static/logo_assets/pdf/Individual/07_Large Icon_Gradient.pdf b/microsite/static/logo_assets/pdf/Individual/07_Large Icon_Gradient.pdf new file mode 100644 index 0000000000..01f600e5bc Binary files /dev/null and b/microsite/static/logo_assets/pdf/Individual/07_Large Icon_Gradient.pdf differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_01_Logo_White.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_01_Logo_White.png new file mode 100644 index 0000000000..84a1dba855 Binary files /dev/null and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_01_Logo_White.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_02_Icon_White.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_02_Icon_White.png new file mode 100644 index 0000000000..29264f8ac0 Binary files /dev/null and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_02_Icon_White.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_03_Logo_Teal.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_03_Logo_Teal.png new file mode 100644 index 0000000000..092599ce63 Binary files /dev/null and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_03_Logo_Teal.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_04_Icon_Teal.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_04_Icon_Teal.png new file mode 100644 index 0000000000..d662301101 Binary files /dev/null and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_04_Icon_Teal.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_05_Logo_Black.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_05_Logo_Black.png new file mode 100644 index 0000000000..7da1808784 Binary files /dev/null and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_05_Logo_Black.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.png new file mode 100644 index 0000000000..5049d54513 Binary files /dev/null and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_07_Large_Icon_Gradient.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_07_Large_Icon_Gradient.png new file mode 100644 index 0000000000..889017fbfb Binary files /dev/null and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_07_Large_Icon_Gradient.png differ diff --git a/microsite/static/logo_assets/svg/Icon_Black.svg b/microsite/static/logo_assets/svg/Icon_Black.svg new file mode 100644 index 0000000000..2eb2dda646 --- /dev/null +++ b/microsite/static/logo_assets/svg/Icon_Black.svg @@ -0,0 +1 @@ +06 Icon_Black \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Icon_Gradient.svg b/microsite/static/logo_assets/svg/Icon_Gradient.svg new file mode 100644 index 0000000000..bbdb4bba27 --- /dev/null +++ b/microsite/static/logo_assets/svg/Icon_Gradient.svg @@ -0,0 +1 @@ +07 Large Icon_Gradient \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Icon_Teal.svg b/microsite/static/logo_assets/svg/Icon_Teal.svg new file mode 100644 index 0000000000..7152749073 --- /dev/null +++ b/microsite/static/logo_assets/svg/Icon_Teal.svg @@ -0,0 +1 @@ +04 Icon_Teal \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Icon_White.svg b/microsite/static/logo_assets/svg/Icon_White.svg new file mode 100644 index 0000000000..c1d6f388b4 --- /dev/null +++ b/microsite/static/logo_assets/svg/Icon_White.svg @@ -0,0 +1 @@ +02 Icon_White \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Logo_Black.svg b/microsite/static/logo_assets/svg/Logo_Black.svg new file mode 100644 index 0000000000..b7d10e429c --- /dev/null +++ b/microsite/static/logo_assets/svg/Logo_Black.svg @@ -0,0 +1 @@ +05 Logo_Black \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Logo_Teal.svg b/microsite/static/logo_assets/svg/Logo_Teal.svg new file mode 100644 index 0000000000..b3babaa39a --- /dev/null +++ b/microsite/static/logo_assets/svg/Logo_Teal.svg @@ -0,0 +1 @@ +03 Logo_Teal \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Logo_White.svg b/microsite/static/logo_assets/svg/Logo_White.svg new file mode 100644 index 0000000000..7b9c543951 --- /dev/null +++ b/microsite/static/logo_assets/svg/Logo_White.svg @@ -0,0 +1 @@ +01 Logo_White \ No newline at end of file diff --git a/microsite/yarn.lock b/microsite/yarn.lock index b322c37dc4..84b5d50d60 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -26,18 +26,18 @@ semver "^5.5.0" "@babel/core@^7.9.0": - version "7.11.1" - resolved "https://registry.npmjs.org/@babel/core/-/core-7.11.1.tgz#2c55b604e73a40dc21b0e52650b11c65cf276643" - integrity sha512-XqF7F6FWQdKGGWAzGELL+aCO1p+lRY5Tj5/tbT3St1G8NaH70jhhDIKknIZaDans0OQBG5wRAldROLHSt44BgQ== + version "7.11.6" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.11.6.tgz#3a9455dc7387ff1bac45770650bc13ba04a15651" + integrity sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg== dependencies: "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.11.0" + "@babel/generator" "^7.11.6" "@babel/helper-module-transforms" "^7.11.0" "@babel/helpers" "^7.10.4" - "@babel/parser" "^7.11.1" + "@babel/parser" "^7.11.5" "@babel/template" "^7.10.4" - "@babel/traverse" "^7.11.0" - "@babel/types" "^7.11.0" + "@babel/traverse" "^7.11.5" + "@babel/types" "^7.11.5" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.1" @@ -47,12 +47,12 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.11.0.tgz#4b90c78d8c12825024568cbe83ee6c9af193585c" - integrity sha512-fEm3Uzw7Mc9Xi//qU20cBKatTfs2aOtKqmvy/Vm7RkJEGFQ4xc9myCfbXxqK//ZS8MR/ciOHw6meGASJuKmDfQ== +"@babel/generator@^7.11.5", "@babel/generator@^7.11.6": + version "7.11.6" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.11.6.tgz#b868900f81b163b4d464ea24545c61cbac4dc620" + integrity sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA== dependencies: - "@babel/types" "^7.11.0" + "@babel/types" "^7.11.5" jsesc "^2.5.1" source-map "^0.5.0" @@ -71,14 +71,14 @@ "@babel/helper-explode-assignable-expression" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-builder-react-jsx-experimental@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.10.5.tgz#f35e956a19955ff08c1258e44a515a6d6248646b" - integrity sha512-Buewnx6M4ttG+NLkKyt7baQn7ScC/Td+e99G914fRU8fGIUivDDgVIQeDHFa5e4CRSJQt58WpNHhsAZgtzVhsg== +"@babel/helper-builder-react-jsx-experimental@^7.10.4", "@babel/helper-builder-react-jsx-experimental@^7.11.5": + version "7.11.5" + resolved "https://registry.npmjs.org/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.11.5.tgz#4ea43dd63857b0a35cd1f1b161dc29b43414e79f" + integrity sha512-Vc4aPJnRZKWfzeCBsqTBnzulVNjABVdahSPhtdMD3Vs80ykx4a87jTHtF/VR+alSrDmNvat7l13yrRHauGcHVw== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-module-imports" "^7.10.4" - "@babel/types" "^7.10.5" + "@babel/types" "^7.11.5" "@babel/helper-builder-react-jsx@^7.10.4": version "7.10.4" @@ -130,11 +130,10 @@ lodash "^4.17.19" "@babel/helper-explode-assignable-expression@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.10.4.tgz#40a1cd917bff1288f699a94a75b37a1a2dbd8c7c" - integrity sha512-4K71RyRQNPRrR85sr5QY4X3VwG4wtVoXZB9+L3r1Gp38DhELyHCtovqydRi7c1Ovb17eRGiQ/FD5s8JdU0Uy5A== + version "7.11.4" + resolved "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.11.4.tgz#2d8e3470252cc17aba917ede7803d4a7a276a41b" + integrity sha512-ux9hm3zR4WV1Y3xXxXkdG/0gxF9nvI0YVmKVhvK9AfMoaQkemL3sJpXw+Xbz65azo8qJiEz2XVDUpK3KYhH3ZQ== dependencies: - "@babel/traverse" "^7.10.4" "@babel/types" "^7.10.4" "@babel/helper-function-name@^7.10.4": @@ -207,14 +206,13 @@ lodash "^4.17.19" "@babel/helper-remap-async-to-generator@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.10.4.tgz#fce8bea4e9690bbe923056ded21e54b4e8b68ed5" - integrity sha512-86Lsr6NNw3qTNl+TBcF1oRZMaVzJtbWTyTko+CQL/tvNvcGYEFKbLXDPxtW0HKk3McNOk4KzY55itGWCAGK5tg== + version "7.11.4" + resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.11.4.tgz#4474ea9f7438f18575e30b0cac784045b402a12d" + integrity sha512-tR5vJ/vBa9wFy3m5LLv2faapJLnDFxNWff2SAYkSE4rLUdbp7CdObYFgI7wK4T/Mj4UzpjPwzR8Pzmr5m7MHGA== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-wrap-function" "^7.10.4" "@babel/template" "^7.10.4" - "@babel/traverse" "^7.10.4" "@babel/types" "^7.10.4" "@babel/helper-replace-supers@^7.10.4": @@ -282,10 +280,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.10.4", "@babel/parser@^7.11.0", "@babel/parser@^7.11.1": - version "7.11.3" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.3.tgz#9e1eae46738bcd08e23e867bab43e7b95299a8f9" - integrity sha512-REo8xv7+sDxkKvoxEywIdsNFiZLybwdI7hcT5uEPyQrSMB4YQ973BfC9OOrD/81MaIjh6UxdulIQXkjmiH3PcA== +"@babel/parser@^7.10.4", "@babel/parser@^7.11.5": + version "7.11.5" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" + integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== "@babel/plugin-proposal-async-generator-functions@^7.10.4": version "7.10.5" @@ -677,11 +675,11 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-react-jsx-development@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.10.4.tgz#6ec90f244394604623880e15ebc3c34c356258ba" - integrity sha512-RM3ZAd1sU1iQ7rI2dhrZRZGv0aqzNQMbkIUCS1txYpi9wHQ2ZHNjo5TwX+UD6pvFW4AbWqLVYvKy5qJSAyRGjQ== + version "7.11.5" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.11.5.tgz#e1439e6a57ee3d43e9f54ace363fb29cefe5d7b6" + integrity sha512-cImAmIlKJ84sDmpQzm4/0q/2xrXlDezQoixy3qoz1NJeZL/8PRon6xZtluvr4H4FzwlDGI5tCcFupMnXGtr+qw== dependencies: - "@babel/helper-builder-react-jsx-experimental" "^7.10.4" + "@babel/helper-builder-react-jsx-experimental" "^7.11.5" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-jsx" "^7.10.4" @@ -787,17 +785,17 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/polyfill@^7.8.7": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.10.4.tgz#915e5bfe61490ac0199008e35ca9d7d151a8e45a" - integrity sha512-8BYcnVqQ5kMD2HXoHInBH7H1b/uP3KdnwCYXOqFnXqguOyuu443WXusbIUbWEfY3Z0Txk0M1uG/8YuAMhNl6zg== + version "7.11.5" + resolved "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.11.5.tgz#df550b2ec53abbc2ed599367ec59e64c7a707bb5" + integrity sha512-FunXnE0Sgpd61pKSj2OSOs1D44rKTD3pGOfGilZ6LGrrIH0QEtJlTjqOqdF8Bs98JmjfGhni2BBkTfv9KcKJ9g== dependencies: core-js "^2.6.5" regenerator-runtime "^0.13.4" "@babel/preset-env@^7.9.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.11.0.tgz#860ee38f2ce17ad60480c2021ba9689393efb796" - integrity sha512-2u1/k7rG/gTh02dylX2kL3S0IJNF+J6bfDSp4DI2Ma8QN6Y9x9pmAax59fsCk6QUQG0yqH47yJWA+u1I1LccAg== + version "7.11.5" + resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.11.5.tgz#18cb4b9379e3e92ffea92c07471a99a2914e4272" + integrity sha512-kXqmW1jVcnB2cdueV+fyBM8estd5mlNfaQi6lwLgRwCby4edpavgbFhiBNjmWA3JpB/yZGSISa7Srf+TwxDQoA== dependencies: "@babel/compat-data" "^7.11.0" "@babel/helper-compilation-targets" "^7.10.4" @@ -861,7 +859,7 @@ "@babel/plugin-transform-unicode-escapes" "^7.10.4" "@babel/plugin-transform-unicode-regex" "^7.10.4" "@babel/preset-modules" "^0.1.3" - "@babel/types" "^7.11.0" + "@babel/types" "^7.11.5" browserslist "^4.12.0" core-js-compat "^3.6.2" invariant "^2.2.2" @@ -869,9 +867,9 @@ semver "^5.5.0" "@babel/preset-modules@^0.1.3": - version "0.1.3" - resolved "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.3.tgz#13242b53b5ef8c883c3cf7dddd55b36ce80fbc72" - integrity sha512-Ra3JXOHBq2xd56xSF7lMKXdjBn3T772Y1Wet3yWnkDly9zHvJki029tAFzvAAK5cf4YV3yoxuP61crYRol6SVg== + version "0.1.4" + resolved "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.4.tgz#362f2b68c662842970fdb5e254ffc8fc1c2e415e" + integrity sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg== dependencies: "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-proposal-unicode-property-regex" "^7.4.4" @@ -893,9 +891,9 @@ "@babel/plugin-transform-react-pure-annotations" "^7.10.4" "@babel/register@^7.9.0": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/register/-/register-7.10.5.tgz#354f3574895f1307f79efe37a51525e52fd38d89" - integrity sha512-eYHdLv43nyvmPn9bfNfrcC4+iYNwdQ8Pxk1MFJuU/U5LpSYl/PH4dFMazCYZDFVi8ueG3shvO+AQfLrxpYulQw== + version "7.11.5" + resolved "https://registry.npmjs.org/@babel/register/-/register-7.11.5.tgz#79becf89e0ddd0fba8b92bc279bc0f5d2d7ce2ea" + integrity sha512-CAml0ioKX+kOAvBQDHa/+t1fgOt3qkTIz0TrRtRAT6XY0m5qYZXR85k6/sLCNPMGhYDlCFHCYuU0ybTJbvlC6w== dependencies: find-cache-dir "^2.0.0" lodash "^4.17.19" @@ -919,25 +917,25 @@ "@babel/parser" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/traverse@^7.10.4", "@babel/traverse@^7.11.0", "@babel/traverse@^7.9.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.0.tgz#9b996ce1b98f53f7c3e4175115605d56ed07dd24" - integrity sha512-ZB2V+LskoWKNpMq6E5UUCrjtDUh5IOTAyIl0dTjIEoXum/iKWkoIEKIRDnUucO6f+2FzNkE0oD4RLKoPIufDtg== +"@babel/traverse@^7.10.4", "@babel/traverse@^7.11.5", "@babel/traverse@^7.9.0": + version "7.11.5" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.5.tgz#be777b93b518eb6d76ee2e1ea1d143daa11e61c3" + integrity sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ== dependencies: "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.11.0" + "@babel/generator" "^7.11.5" "@babel/helper-function-name" "^7.10.4" "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/parser" "^7.11.0" - "@babel/types" "^7.11.0" + "@babel/parser" "^7.11.5" + "@babel/types" "^7.11.5" debug "^4.1.0" globals "^11.1.0" lodash "^4.17.19" -"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.4.4", "@babel/types@^7.9.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.0.tgz#2ae6bf1ba9ae8c3c43824e5861269871b206e90d" - integrity sha512-O53yME4ZZI0jO1EVGtF1ePGl0LHirG4P1ibcD80XyzZcKhcMFeCXmh4Xb1ifGBIV233Qg12x4rBfQgA+tmOukA== +"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.11.5", "@babel/types@^7.4.4", "@babel/types@^7.9.0": + version "7.11.5" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" + integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q== dependencies: "@babel/helper-validator-identifier" "^7.10.4" lodash "^4.17.19" @@ -974,9 +972,9 @@ integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== "@types/node@*": - version "14.0.27" - resolved "https://registry.npmjs.org/@types/node/-/node-14.0.27.tgz#a151873af5a5e851b51b3b065c9e63390a9e0eb1" - integrity sha512-kVrqXhbclHNHGu9ztnAwSncIgJv/FaxmzXJvGXNdcCpV1b8u1/Mi6z6m0vwy0LzKeXFTPLH0NzwmoJ3fNCIq0g== + version "14.6.4" + resolved "https://registry.npmjs.org/@types/node/-/node-14.6.4.tgz#a145cc0bb14ef9c4777361b7bbafa5cf8e3acb5a" + integrity sha512-Wk7nG1JSaMfMpoMJDKUsWYugliB2Vy55pdjLpmLixeyMi7HizW2I/9QoxsPCkXl3dO+ZOVqPumKaDUv5zJu2uQ== "@types/q@^1.5.1": version "1.5.4" @@ -997,9 +995,9 @@ address@1.1.2, address@^1.0.1: integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== ajv@^6.12.3: - version "6.12.3" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.3.tgz#18c5af38a111ddeb4f2697bd78d68abc1cabd706" - integrity sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA== + version "6.12.4" + resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" + integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -1209,9 +1207,9 @@ aws-sign2@~0.7.0: integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= aws4@^1.8.0: - version "1.10.0" - resolved "https://registry.npmjs.org/aws4/-/aws4-1.10.0.tgz#a17b3a8ea811060e74d47d306122400ad4497ae2" - integrity sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA== + version "1.10.1" + resolved "https://registry.npmjs.org/aws4/-/aws4-1.10.1.tgz#e1e82e4f3e999e2cfd61b161280d16a111f86428" + integrity sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA== babel-code-frame@^6.22.0: version "6.26.0" @@ -1330,9 +1328,9 @@ bindings@^1.5.0: file-uri-to-path "1.0.0" bl@^1.0.0: - version "1.2.2" - resolved "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c" - integrity sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA== + version "1.2.3" + resolved "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7" + integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww== dependencies: readable-stream "^2.3.5" safe-buffer "^5.1.1" @@ -1402,12 +1400,12 @@ browserslist@4.7.0: node-releases "^1.1.29" browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.5: - version "4.14.0" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.14.0.tgz#2908951abfe4ec98737b72f34c3bcedc8d43b000" - integrity sha512-pUsXKAF2lVwhmtpeA3LJrZ76jXuusrNyhduuQs7CDFf9foT4Y38aQOserd2lMe5DSSrjf3fx34oHwryuvxAUgQ== + version "4.14.1" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.14.1.tgz#cb2b490ba881d45dc3039078c7ed04411eaf3fa3" + integrity sha512-zyBTIHydW37pnb63c7fHFXUG6EcqWOqoMdDx6cdyaDFriZ20EoVxcE95S54N+heRqY8m8IUgB5zYta/gCwSaaA== dependencies: - caniuse-lite "^1.0.30001111" - electron-to-chromium "^1.3.523" + caniuse-lite "^1.0.30001124" + electron-to-chromium "^1.3.562" escalade "^3.0.2" node-releases "^1.1.60" @@ -1532,10 +1530,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001111: - version "1.0.30001113" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001113.tgz#22016ab55b5a8b04fa00ca342d9ee1b98df48065" - integrity sha512-qMvjHiKH21zzM/VDZr6oosO6Ri3U0V2tC015jRXjOecwQCJtsU5zklTNTk31jQbIOP8gha0h1ccM/g0ECP+4BA== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001124: + version "1.0.30001124" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001124.tgz#5d9998190258e11630d674fc50ea8e579ae0ced2" + integrity sha512-zQW8V3CdND7GHRH6rxm6s59Ww4g/qGWTheoboW9nfeMg7sUoopIfKCcNZUjwYRCOrvereh3kwDpZj4VLQ7zGtA== caseless@~0.12.0: version "0.12.0" @@ -2230,10 +2228,10 @@ dir-glob@2.0.0: arrify "^1.0.1" path-type "^3.0.0" -docusaurus@^1.14.4: - version "1.14.6" - resolved "https://registry.npmjs.org/docusaurus/-/docusaurus-1.14.6.tgz#ffab9f6dafe8c48c477e0ebc7f491e554143b2b5" - integrity sha512-Hpo6xqYIHwazwuhXW25AKYv/os+dWoJ87qql/m1j1xp83h/BnfYV2l8PA8zLggF1wGUbJQbTx7GWo6QvD8z+4Q== +docusaurus@^2.0.0-alpha.64: + version "2.0.0-alpha.64" + resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.64.tgz#7833960e9d338403894a27b79058aa4076a676d3" + integrity sha512-ARCx0GwAvc5qx7AHvRVZidZuoDTfaaGXzgmkU23NahU6jzO/aK2Q1bH8IKNEQ5C2JuDerQ/hHDh80N20ijk82g== dependencies: "@babel/core" "^7.9.0" "@babel/plugin-proposal-class-properties" "^7.8.3" @@ -2379,9 +2377,9 @@ duplexer3@^0.1.4: integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= duplexer@^0.1.1: - version "0.1.1" - resolved "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" - integrity sha1-rOb/gIwc5mtX0ev5eXessCM0z8E= + version "0.1.2" + resolved "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" + integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== ecc-jsbn@~0.1.1: version "0.1.2" @@ -2396,10 +2394,10 @@ ee-first@1.1.1: resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.523: - version "1.3.529" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.529.tgz#a7eed559bc848a7c8d95026be7d8929e3f9af169" - integrity sha512-n3sriLldqNyjBlosbnPftjCY+m1dVOY307I1Y0HaHAqDGe3hRvK7ksJwWd+qs599ybR4jobCo1+7zXM9GyNMSA== +electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.562: + version "1.3.562" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.562.tgz#79c20277ee1c8d0173a22af00e38433b752bc70f" + integrity sha512-WhRe6liQ2q/w1MZc8mD8INkenHivuHdrr4r5EQHNomy3NJux+incP6M6lDMd0paShP3MD0WGe5R1TWmEClf+Bg== "emoji-regex@>=6.0.0 <=6.1.1": version "6.1.1" @@ -3862,7 +3860,7 @@ js-tokens@^3.0.2: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@^3.13.1, js-yaml@^3.8.1: +js-yaml@^3.13.1, js-yaml@^3.14.0, js-yaml@^3.8.1: version "3.14.0" resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== @@ -4153,9 +4151,9 @@ lodash.uniq@^4.5.0: integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@~4.17.10: - version "4.17.19" - resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" - integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== + version "4.17.20" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== logalot@^2.0.0: version "2.1.0" @@ -4908,9 +4906,9 @@ posix-character-classes@^0.1.0: integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= postcss-calc@^7.0.1: - version "7.0.3" - resolved "https://registry.npmjs.org/postcss-calc/-/postcss-calc-7.0.3.tgz#d65cca92a3c52bf27ad37a5f732e0587b74f1623" - integrity sha512-IB/EAEmZhIMEIhG7Ov4x+l47UaXOS1n2f4FBUk/aKllQhtSCxWhTzn0nJgkqN7fo/jcWySvWTSB6Syk9L+31bA== + version "7.0.4" + resolved "https://registry.npmjs.org/postcss-calc/-/postcss-calc-7.0.4.tgz#5e177ddb417341e6d4a193c5d9fd8ada79094f8b" + integrity sha512-0I79VRAd1UTkaHzY9w83P39YGO/M3bG7/tNLrHGEunBolfoGM0hSjrGvjoeaj0JE/zIw5GsI2KZ0UwDJqv5hjw== dependencies: postcss "^7.0.27" postcss-selector-parser "^6.0.2" @@ -5288,9 +5286,9 @@ query-string@^5.0.1: strict-uri-encode "^1.0.0" querystringify@^2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/querystringify/-/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e" - integrity sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA== + version "2.2.0" + resolved "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== randomatic@^3.0.0: version "3.1.1" @@ -6146,9 +6144,9 @@ supports-color@^6.1.0: has-flag "^3.0.0" supports-color@^7.1.0: - version "7.1.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" - integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== + version "7.2.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== dependencies: has-flag "^4.0.0" @@ -6459,9 +6457,9 @@ upath@^1.1.1: integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== uri-js@^4.2.2: - version "4.2.2" - resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" - integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== + version "4.4.0" + resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.0.tgz#aa714261de793e8a82347a7bcc9ce74e86f28602" + integrity sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g== dependencies: punycode "^2.1.0" diff --git a/mkdocs.yml b/mkdocs.yml index 8c97a4f1fd..a74ef5737f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,8 +7,13 @@ nav: - Backstage architecture: 'overview/architecture-overview.md' - Architecture and terminology: 'overview/architecture-terminology.md' - Roadmap: 'overview/roadmap.md' + - Vision: 'overview/vision.md' + - The Spotify story: 'overview/background.md' + - Strategies for adopting: 'overview/adopting.md' + - Logo assets: 'overview/logos.md' - Getting started: - - Running Backstage locally: 'getting-started/index.md' + - Getting Started: 'getting-started/index.md' + - Running Backstage locally: 'getting-started/running-backstage-locally.md' - Installation: 'getting-started/installation.md' - Local development: 'getting-started/development-environment.md' - Demo deployment: https://backstage-demo.roadie.io @@ -25,26 +30,26 @@ nav: - Overview: 'features/software-catalog/index.md' - System model: 'features/software-catalog/system-model.md' - YAML File Format: 'features/software-catalog/descriptor-format.md' + - Well-known Annotations: 'features/software-catalog/well-known-annotations.md' + - Configuration: 'features/software-catalog/configuration.md' - Extending the model: 'features/software-catalog/extending-the-model.md' - External integrations: 'features/software-catalog/external-integrations.md' - API: 'features/software-catalog/api.md' - Software creation templates: - Overview: 'features/software-templates/index.md' + - Installation: 'features/software-templates/installation.md' - Adding templates: 'features/software-templates/adding-templates.md' - Extending the Scaffolder: - Overview: 'features/software-templates/extending/index.md' - Create your own Templater: 'features/software-templates/extending/create-your-own-templater.md' - Create your own Publisher: 'features/software-templates/extending/create-your-own-publisher.md' - Create your own Preparer: 'features/software-templates/extending/create-your-own-preparer.md' - - Docs-like-code: + - TechDocs: - Overview: 'features/techdocs/README.md' - Getting Started: 'features/techdocs/getting-started.md' - Concepts: 'features/techdocs/concepts.md' - - Reading Documentation: 'features/techdocs/reading-documentation.md' - - Writing Documentation: 'features/techdocs/writing-documentation.md' - - Publishing Documentation: 'features/techdocs/publishing-documentation.md' - - Contributing: 'features/techdocs/contributing.md' - - Debugging: 'features/techdocs/debugging.md' + - TechDocs Architecture: 'features/techdocs/architecture.md' + - Creating and Publishing Documentation: 'features/techdocs/creating-and-publishing.md' - FAQ: 'features/techdocs/FAQ.md' - Plugins: - Overview: 'plugins/index.md' @@ -70,6 +75,7 @@ nav: - Overview: 'auth/index.md' - Add auth provider: 'auth/add-auth-provider.md' - Auth backend: 'auth/auth-backend.md' + - Auth backend class structure: 'auth/auth-backend-classes.md' - OAuth: 'auth/oauth.md' - Glossary: 'auth/glossary.md' - Designing for Backstage: diff --git a/package.json b/package.json index b24c0ed2fb..c64b7f6951 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,12 @@ "node": "12" }, "scripts": { - "dev": "concurrently 'yarn start' 'yarn start-backend'", + "dev": "concurrently \"yarn start\" \"yarn start-backend\"", "start": "yarn workspace example-app start", "start-backend": "yarn workspace example-backend start", "build": "lerna run build", "tsc": "tsc", + "tsc:full": "tsc --skipLibCheck false --incremental false", "clean": "backstage-cli clean && lerna run clean", "diff": "lerna run diff --", "test": "lerna run test --since origin/master -- --coverage", @@ -18,11 +19,12 @@ "lint:all": "lerna run lint --", "lint:type-deps": "node scripts/check-type-dependencies.js", "docgen": "lerna run docgen", - "docker-build": "yarn workspace example-app build && docker build . -t spotify/backstage", - "docker-build:all": "yarn tsc && yarn build && yarn docker-build && yarn workspace example-backend build-image", + "docker-build:app": "yarn workspace example-app build && docker build . -t spotify/backstage", + "docker-build": "yarn tsc && yarn workspace example-backend build-image", "create-plugin": "backstage-cli create-plugin", "remove-plugin": "backstage-cli remove-plugin", "release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push --force-publish; fi", + "prettier:check": "prettier --check .", "lerna": "lerna", "storybook": "yarn workspace storybook start", "build-storybook": "yarn workspace storybook build-storybook" @@ -30,8 +32,7 @@ "workspaces": { "packages": [ "packages/*", - "plugins/*", - "microsite" + "plugins/*" ] }, "version": "1.0.0", @@ -60,5 +61,10 @@ "*.{json,md}": [ "prettier --write" ] + }, + "jest": { + "transformModules": [ + "@kyma-project/asyncapi-react" + ] } } diff --git a/packages/app/package.json b/packages/app/package.json index 0e2cb677a4..bf0f340a40 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,33 +1,37 @@ { "name": "example-app", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": true, + "bundled": true, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/plugin-api-docs": "^0.1.1-alpha.18", - "@backstage/plugin-catalog": "^0.1.1-alpha.18", - "@backstage/plugin-circleci": "^0.1.1-alpha.18", - "@backstage/plugin-explore": "^0.1.1-alpha.18", - "@backstage/plugin-github-actions": "^0.1.1-alpha.18", - "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.18", - "@backstage/plugin-graphiql": "^0.1.1-alpha.18", - "@backstage/plugin-jenkins": "^0.1.1-alpha.18", - "@backstage/plugin-lighthouse": "^0.1.1-alpha.18", - "@backstage/plugin-newrelic": "^0.1.1-alpha.18", - "@backstage/plugin-register-component": "^0.1.1-alpha.18", - "@backstage/plugin-rollbar": "^0.1.1-alpha.18", - "@backstage/plugin-scaffolder": "^0.1.1-alpha.18", - "@backstage/plugin-sentry": "^0.1.1-alpha.18", - "@backstage/plugin-tech-radar": "^0.1.1-alpha.18", - "@backstage/plugin-techdocs": "^0.1.1-alpha.18", - "@backstage/plugin-welcome": "^0.1.1-alpha.18", - "@backstage/test-utils": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/plugin-api-docs": "^0.1.1-alpha.21", + "@backstage/plugin-catalog": "^0.1.1-alpha.21", + "@backstage/plugin-circleci": "^0.1.1-alpha.21", + "@backstage/plugin-explore": "^0.1.1-alpha.21", + "@backstage/plugin-gcp-projects": "^0.1.1-alpha.21", + "@backstage/plugin-github-actions": "^0.1.1-alpha.21", + "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.21", + "@backstage/plugin-graphiql": "^0.1.1-alpha.21", + "@backstage/plugin-jenkins": "^0.1.1-alpha.21", + "@backstage/plugin-lighthouse": "^0.1.1-alpha.21", + "@backstage/plugin-newrelic": "^0.1.1-alpha.21", + "@backstage/plugin-register-component": "^0.1.1-alpha.21", + "@backstage/plugin-rollbar": "^0.1.1-alpha.21", + "@backstage/plugin-scaffolder": "^0.1.1-alpha.21", + "@backstage/plugin-sentry": "^0.1.1-alpha.21", + "@backstage/plugin-tech-radar": "^0.1.1-alpha.21", + "@backstage/plugin-techdocs": "^0.1.1-alpha.21", + "@backstage/plugin-welcome": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.0.0", - "@roadiehq/backstage-plugin-travis-ci": "^0.1.3", + "@roadiehq/backstage-plugin-github-pull-requests": "0.3.0", + "@roadiehq/backstage-plugin-travis-ci": "^0.1.4", "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^16.12.0", diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 26c3466ba1..cddd093855 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -27,6 +27,12 @@ describe('App', () => { data: { app: { title: 'Test' }, backend: { baseUrl: 'http://localhost:7000' }, + lighthouse: { + baseUrl: 'http://localhost:3003', + }, + techdocs: { + storageUrl: 'http://localhost:7000/techdocs/static/docs', + }, }, context: 'test', }, diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 993fd77978..667b14366f 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -19,6 +19,7 @@ import { AlertDisplay, OAuthRequestDialog, SignInPage, + createRouteRef, } from '@backstage/core'; import React, { FC } from 'react'; import Root from './components/Root'; @@ -26,6 +27,15 @@ import * as plugins from './plugins'; import { apis } from './apis'; import { hot } from 'react-hot-loader/root'; import { providers } from './identityProviders'; +import { Router as CatalogRouter } from '@backstage/plugin-catalog'; +import { Router as DocsRouter } from '@backstage/plugin-techdocs'; +import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql'; +import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; +import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; +import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component'; +import { Route, Routes, Navigate } from 'react-router'; + +import { EntityPage } from './components/catalog/EntityPage'; const app = createApp({ apis, @@ -46,7 +56,34 @@ const app = createApp({ const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); -const AppRoutes = app.getRoutes(); +const deprecatedAppRoutes = app.getRoutes(); + +const catalogRouteRef = createRouteRef({ + path: '/catalog', + title: 'Service Catalog', +}); + +const AppRoutes = () => ( + + + } + /> + } /> + } + /> + } /> + } /> + } + /> + {...deprecatedAppRoutes} + +); const App: FC<{}> = () => ( diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index d9e3302146..7f57f6f970 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -15,187 +15,58 @@ */ import { - ApiRegistry, - alertApiRef, errorApiRef, - AlertApiForwarder, - ConfigApi, - ErrorApiForwarder, - ErrorAlerter, - featureFlagsApiRef, - FeatureFlags, - GoogleAuth, - GithubAuth, - OAuth2, - OktaAuth, - GitlabAuth, - oauthRequestApiRef, - OAuthRequestManager, - googleAuthApiRef, + discoveryApiRef, + UrlPatternDiscovery, githubAuthApiRef, - oauth2ApiRef, - oktaAuthApiRef, - gitlabAuthApiRef, - storageApiRef, - WebStorage, + createApiFactory, + configApiRef, } from '@backstage/core'; -import { - lighthouseApiRef, - LighthouseRestApi, -} from '@backstage/plugin-lighthouse'; - -import { techRadarApiRef, TechRadar } from '@backstage/plugin-tech-radar'; - -import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci'; -import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; - -import { gitOpsApiRef, GitOpsRestApi } from '@backstage/plugin-gitops-profiles'; import { graphQlBrowseApiRef, GraphQLEndpoints, } from '@backstage/plugin-graphiql'; -import { scaffolderApiRef, ScaffolderApi } from '@backstage/plugin-scaffolder'; -import { rollbarApiRef, RollbarClient } from '@backstage/plugin-rollbar'; import { - GithubActionsClient, - githubActionsApiRef, -} from '@backstage/plugin-github-actions'; -import { jenkinsApiRef, JenkinsApi } from '@backstage/plugin-jenkins'; + TravisCIApi, + travisCIApiRef, +} from '@roadiehq/backstage-plugin-travis-ci'; +import { + GithubPullRequestsClient, + githubPullRequestsApiRef, +} from '@roadiehq/backstage-plugin-github-pull-requests'; -import { TravisCIApi, travisCIApiRef } from '@roadiehq/backstage-plugin-travis-ci'; +export const apis = [ + // TODO(Rugvip): migrate to use /api + createApiFactory({ + api: discoveryApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + UrlPatternDiscovery.compile( + `${configApi.getString('backend.baseUrl')}/{{ pluginId }}`, + ), + }), + createApiFactory({ + api: graphQlBrowseApiRef, + deps: { errorApi: errorApiRef, githubAuthApi: githubAuthApiRef }, + factory: ({ errorApi, githubAuthApi }) => + GraphQLEndpoints.from([ + GraphQLEndpoints.create({ + id: 'gitlab', + title: 'GitLab', + url: 'https://gitlab.com/api/graphql', + }), + GraphQLEndpoints.github({ + id: 'github', + title: 'GitHub', + errorApi, + githubAuthApi, + }), + ]), + }), -export const apis = (config: ConfigApi) => { - // eslint-disable-next-line no-console - console.log(`Creating APIs for ${config.getString('app.title')}`); - - const backendUrl = config.getString('backend.baseUrl'); - - const builder = ApiRegistry.builder(); - - const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); - const errorApi = builder.add( - errorApiRef, - new ErrorAlerter(alertApi, new ErrorApiForwarder()), - ); - - builder.add(storageApiRef, WebStorage.create({ errorApi })); - builder.add( - circleCIApiRef, - new CircleCIApi(`${backendUrl}/proxy/circleci/api`), - ); - - builder.add(jenkinsApiRef, new JenkinsApi(`${backendUrl}/proxy/jenkins/api`)); - - builder.add(githubActionsApiRef, new GithubActionsClient()); - - builder.add(featureFlagsApiRef, new FeatureFlags()); - - builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003')); - - builder.add(travisCIApiRef, new TravisCIApi()); - - const oauthRequestApi = builder.add( - oauthRequestApiRef, - new OAuthRequestManager(), - ); - - builder.add( - googleAuthApiRef, - GoogleAuth.create({ - apiOrigin: backendUrl, - basePath: '/auth/', - oauthRequestApi, - }), - ); - - const githubAuthApi = builder.add( - githubAuthApiRef, - GithubAuth.create({ - apiOrigin: backendUrl, - basePath: '/auth/', - oauthRequestApi, - }), - ); - - builder.add( - oktaAuthApiRef, - OktaAuth.create({ - apiOrigin: backendUrl, - basePath: '/auth/', - oauthRequestApi, - }), - ); - - builder.add( - gitlabAuthApiRef, - GitlabAuth.create({ - apiOrigin: backendUrl, - basePath: '/auth/', - oauthRequestApi, - }), - ); - - builder.add( - oauth2ApiRef, - OAuth2.create({ - apiOrigin: backendUrl, - basePath: '/auth/', - oauthRequestApi, - }), - ); - - builder.add( - techRadarApiRef, - new TechRadar({ - width: 1500, - height: 800, - }), - ); - - builder.add( - catalogApiRef, - new CatalogClient({ - apiOrigin: backendUrl, - basePath: '/catalog', - }), - ); - - builder.add( - scaffolderApiRef, - new ScaffolderApi({ - apiOrigin: backendUrl, - basePath: '/scaffolder/v1', - }), - ); - - builder.add(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')); - - builder.add( - graphQlBrowseApiRef, - GraphQLEndpoints.from([ - GraphQLEndpoints.create({ - id: 'gitlab', - title: 'GitLab', - url: 'https://gitlab.com/api/graphql', - }), - GraphQLEndpoints.github({ - id: 'github', - title: 'GitHub', - errorApi, - githubAuthApi, - }), - ]), - ); - - builder.add( - rollbarApiRef, - new RollbarClient({ - apiOrigin: backendUrl, - basePath: '/rollbar', - }), - ); - - return builder.build(); -}; + // TODO: move to plugins + createApiFactory(travisCIApiRef, new TravisCIApi()), + createApiFactory(githubPullRequestsApiRef, new GithubPullRequestsClient()), +]; diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 1d32b6e16e..e91504d619 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -18,9 +18,7 @@ import React, { FC, useContext } from 'react'; import PropTypes from 'prop-types'; import { Link, makeStyles } from '@material-ui/core'; import HomeIcon from '@material-ui/icons/Home'; -import ExploreIcon from '@material-ui/icons/Explore'; import ExtensionIcon from '@material-ui/icons/Extension'; -import BuildIcon from '@material-ui/icons/BuildRounded'; import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; @@ -39,6 +37,7 @@ import { SidebarUserSettings, SidebarThemeToggle, SidebarPinButton, + DefaultProviderSettings, } from '@backstage/core'; import { NavLink } from 'react-router-dom'; import { graphiQLRouteRef } from '@backstage/plugin-graphiql'; @@ -89,8 +88,7 @@ const Root: FC<{}> = ({ children }) => ( {/* Global nav, not org-specific */} - - + @@ -98,7 +96,6 @@ const Root: FC<{}> = ({ children }) => ( - = ({ children }) => ( - + } /> {children} diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx new file mode 100644 index 0000000000..c382abc3a8 --- /dev/null +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -0,0 +1,154 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Router as GitHubActionsRouter, + isPluginApplicableToEntity as isGitHubActionsAvailable, +} from '@backstage/plugin-github-actions'; +import { + Router as JenkinsRouter, + isPluginApplicableToEntity as isJenkinsAvailable, + LatestRunCard as JenkinsLatestRunCard, +} from '@backstage/plugin-jenkins'; +import { + Router as CircleCIRouter, + isPluginApplicableToEntity as isCircleCIAvailable, +} from '@backstage/plugin-circleci'; +import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs'; +import { Router as SentryRouter } from '@backstage/plugin-sentry'; +import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; +import React from 'react'; +import { + AboutCard, + EntityPageLayout, + useEntity, +} from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; +import { Grid } from '@material-ui/core'; +import { WarningPanel } from '@backstage/core'; + +const CICDSwitcher = ({ entity }: { entity: Entity }) => { + // This component is just an example of how you can implement your company's logic in entity page. + // You can for example enforce that all components of type 'service' should use GitHubActions + switch (true) { + case isJenkinsAvailable(entity): + return ; + case isGitHubActionsAvailable(entity): + return ; + case isCircleCIAvailable(entity): + return ; + default: + return ( + + No CI/CD is available for this entity. Check corresponding + annotations! + + ); + } +}; + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + + + + + {isJenkinsAvailable(entity) && ( + + + + )} + +); + +const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + } + /> + } + /> + } + /> + +); + +const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + } + /> + } + /> + +); +const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + +); + +export const EntityPage = () => { + const { entity } = useEntity(); + switch (entity?.spec?.type) { + case 'service': + return ; + case 'website': + return ; + default: + return ; + } +}; diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index e80b1d6fea..fab0e92577 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -19,6 +19,7 @@ import { gitlabAuthApiRef, oktaAuthApiRef, githubAuthApiRef, + microsoftAuthApiRef, } from '@backstage/core'; export const providers = [ @@ -28,16 +29,22 @@ export const providers = [ message: 'Sign In using Google', apiRef: googleAuthApiRef, }, + { + id: 'microsoft-auth-provider', + title: 'Microsoft', + message: 'Sign In using Microsoft Azure AD', + apiRef: microsoftAuthApiRef, + }, { id: 'gitlab-auth-provider', - title: 'Gitlab', - message: 'Sign In using Gitlab', + title: 'GitLab', + message: 'Sign In using GitLab', apiRef: gitlabAuthApiRef, }, { id: 'github-auth-provider', - title: 'Github', - message: 'Sign In using Github', + title: 'GitHub', + message: 'Sign In using GitHub', apiRef: githubAuthApiRef, }, { diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 4ec19c74f3..b36d0323e0 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -31,3 +31,5 @@ export { plugin as Newrelic } from '@backstage/plugin-newrelic'; export { plugin as TravisCI } from '@roadiehq/backstage-plugin-travis-ci'; export { plugin as Jenkins } from '@backstage/plugin-jenkins'; export { plugin as ApiDocs } from '@backstage/plugin-api-docs'; +export { plugin as GithubPullRequests } from '@roadiehq/backstage-plugin-github-pull-requests'; +export { plugin as GcpProjects } from '@backstage/plugin-gcp-projects'; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index e21d0eb0d7..c9103375eb 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -29,22 +29,36 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli-common": "^0.1.1-alpha.18", - "@backstage/config": "^0.1.1-alpha.18", - "@backstage/config-loader": "^0.1.1-alpha.18", + "@backstage/cli-common": "^0.1.1-alpha.21", + "@backstage/config": "^0.1.1-alpha.21", + "@backstage/config-loader": "^0.1.1-alpha.21", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "compression": "^1.7.4", "cors": "^2.8.5", "express": "^4.17.1", + "express-prom-bundle": "^6.1.0", "express-promise-router": "^3.0.3", "helmet": "^4.0.0", + "knex": "^0.21.1", + "lodash": "^4.17.15", "morgan": "^1.10.0", + "prom-client": "^12.0.0", + "selfsigned": "^1.10.7", "stoppable": "^1.1.0", - "winston": "^3.2.1" + "winston": "^3.2.1", + "logform": "^2.1.1" + }, + "peerDependencies": { + "pg-connection-string": "^2.3.0" + }, + "peerDependenciesMeta": { + "pg-connection-string": { + "optional": true + } }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", "@types/compression": "^1.7.0", "@types/http-errors": "^1.6.3", "@types/morgan": "^1.9.0", diff --git a/packages/backend-common/src/database/config.test.ts b/packages/backend-common/src/database/config.test.ts new file mode 100644 index 0000000000..ab29bc19ba --- /dev/null +++ b/packages/backend-common/src/database/config.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mergeDatabaseConfig } from './config'; + +describe('config', () => { + describe(mergeDatabaseConfig, () => { + it('does not require overrides', () => { + expect( + mergeDatabaseConfig({ + client: 'pg', + connection: '', + useNullAsDefault: true, + }), + ).toEqual({ + client: 'pg', + connection: '', + useNullAsDefault: true, + }); + }); + + it('accepts an empty object', () => { + expect( + mergeDatabaseConfig( + { + client: 'pg', + connection: '', + useNullAsDefault: true, + }, + {}, + ), + ).toEqual({ + client: 'pg', + connection: '', + useNullAsDefault: true, + }); + }); + + it('does a deep merge', () => { + expect( + mergeDatabaseConfig( + { + client: 'pg', + connection: { + database: 'dbname', + ssl: { + ca: 'foo', + }, + }, + }, + { + connection: { + password: 'secret', + ssl: { + rejectUnauthorized: true, + }, + }, + pool: { min: 0, max: 7 }, + }, + ), + ).toEqual({ + client: 'pg', + connection: { + database: 'dbname', + password: 'secret', + ssl: { + rejectUnauthorized: true, + ca: 'foo', + }, + }, + pool: { min: 0, max: 7 }, + }); + }); + + it('replaces a string connection', () => { + expect( + mergeDatabaseConfig( + { + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }, + { + connection: { + filename: '/path/to/file', + }, + }, + ), + ).toEqual({ + client: 'sqlite3', + connection: { + filename: '/path/to/file', + }, + useNullAsDefault: true, + }); + }); + }); +}); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/types.ts b/packages/backend-common/src/database/config.ts similarity index 68% rename from packages/core-api/src/apis/implementations/auth/gitlab/types.ts rename to packages/backend-common/src/database/config.ts index b03dfec084..80abc06aa2 100644 --- a/packages/core-api/src/apis/implementations/auth/gitlab/types.ts +++ b/packages/backend-common/src/database/config.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -import { ProfileInfo, BackstageIdentity } from '../../../definitions'; +import { merge } from 'lodash'; -export type GitlabSession = { - providerInfo: { - accessToken: string; - scopes: Set; - expiresAt: Date; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; +/** + * Merges database objects together + * + * @param config The base config + * @param overrides Any additional overrides + */ +export function mergeDatabaseConfig(config: any, ...overrides: any[]) { + return merge(config, ...overrides); +} diff --git a/packages/backend-common/src/database/connection.test.ts b/packages/backend-common/src/database/connection.test.ts new file mode 100644 index 0000000000..2cc3a54e06 --- /dev/null +++ b/packages/backend-common/src/database/connection.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { createDatabaseClient } from './connection'; + +describe('database connection', () => { + const createConfig = (data: any) => + ConfigReader.fromConfigs([ + { + context: '', + data, + }, + ]); + + describe(createDatabaseClient, () => { + it('returns a postgres connection', () => { + expect( + createDatabaseClient( + createConfig({ + client: 'pg', + connection: { + host: 'acme', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }), + ), + ).toBeTruthy(); + }); + + it('returns an sqlite connection', () => { + expect( + createDatabaseClient( + createConfig({ + client: 'sqlite3', + connection: ':memory:', + }), + ), + ).toBeTruthy(); + }); + + it('tries to create a mysql connection as a passthrough', () => { + expect(() => + createDatabaseClient( + createConfig({ + client: 'mysql', + connection: { + host: '127.0.0.1', + user: 'foo', + password: 'bar', + database: 'dbname', + }, + }), + ), + ).toThrowError(/Cannot find module 'mysql'/); + }); + + it('accepts overrides', () => { + expect( + createDatabaseClient( + createConfig({ + client: 'pg', + connection: { + host: 'acme', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }), + { + connection: { + database: 'foo', + }, + }, + ), + ).toBeTruthy(); + }); + + it('throws an error without a client', () => { + expect(() => + createDatabaseClient( + createConfig({ + connection: '', + }), + ), + ).toThrowError(); + }); + + it('throws an error without a connection', () => { + expect(() => + createDatabaseClient( + createConfig({ + client: 'pg', + }), + ), + ).toThrowError(); + }); + }); +}); diff --git a/packages/backend-common/src/database/connection.ts b/packages/backend-common/src/database/connection.ts new file mode 100644 index 0000000000..009a85239f --- /dev/null +++ b/packages/backend-common/src/database/connection.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import knex from 'knex'; +import { Config } from '@backstage/config'; +import { mergeDatabaseConfig } from './config'; +import { createPgDatabaseClient } from './postgres'; +import { createSqliteDatabaseClient } from './sqlite3'; + +type DatabaseClient = 'pg' | 'sqlite3' | string; + +/** + * Creates a knex database connection + * + * @param dbConfig The database config + * @param overrides Additional options to merge with the config + */ +export function createDatabaseClient( + dbConfig: Config, + overrides?: Partial, +) { + const client: DatabaseClient = dbConfig.getString('client'); + + if (client === 'pg') { + return createPgDatabaseClient(dbConfig, overrides); + } else if (client === 'sqlite3') { + return createSqliteDatabaseClient(dbConfig); + } + + return knex(mergeDatabaseConfig(dbConfig.get(), overrides)); +} + +/** + * Alias for createDatabaseClient + * @deprecated Use createDatabaseClient instead + */ +export const createDatabase = createDatabaseClient; diff --git a/plugins/jenkins/src/components/PluginHeader/index.ts b/packages/backend-common/src/database/index.ts similarity index 94% rename from plugins/jenkins/src/components/PluginHeader/index.ts rename to packages/backend-common/src/database/index.ts index 4de972f6f2..38d3d6224b 100644 --- a/plugins/jenkins/src/components/PluginHeader/index.ts +++ b/packages/backend-common/src/database/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './PluginHeader'; + +export * from './connection'; diff --git a/packages/backend-common/src/database/postgres.test.ts b/packages/backend-common/src/database/postgres.test.ts new file mode 100644 index 0000000000..82161dc53c --- /dev/null +++ b/packages/backend-common/src/database/postgres.test.ts @@ -0,0 +1,207 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config, ConfigReader } from '@backstage/config'; +import { + createPgDatabaseClient, + buildPgDatabaseConfig, + getPgConnectionConfig, + parsePgConnectionString, +} from './postgres'; + +describe('postgres', () => { + const createMockConnection = () => ({ + host: 'acme', + user: 'foo', + password: 'bar', + database: 'foodb', + }); + + const createMockConnectionString = () => + 'postgresql://foo:bar@acme:5432/foodb'; + + const createConfig = (connection: any): Config => + ConfigReader.fromConfigs([ + { + context: '', + data: { + client: 'pg', + connection, + }, + }, + ]); + + describe(buildPgDatabaseConfig, () => { + it('builds a postgres config', () => { + const mockConnection = createMockConnection(); + + expect(buildPgDatabaseConfig(createConfig(mockConnection))).toEqual({ + client: 'pg', + connection: mockConnection, + useNullAsDefault: true, + }); + }); + + it('builds a connection string config', () => { + const mockConnectionString = createMockConnectionString(); + + expect(buildPgDatabaseConfig(createConfig(mockConnectionString))).toEqual( + { + client: 'pg', + connection: mockConnectionString, + useNullAsDefault: true, + }, + ); + }); + + it('overrides the database name', () => { + const mockConnection = createMockConnection(); + + expect( + buildPgDatabaseConfig(createConfig(mockConnection), { + connection: { database: 'other_db' }, + }), + ).toEqual({ + client: 'pg', + connection: { + ...mockConnection, + database: 'other_db', + }, + useNullAsDefault: true, + }); + }); + + it('adds additional config settings', () => { + const mockConnection = createMockConnection(); + + expect( + buildPgDatabaseConfig(createConfig(mockConnection), { + connection: { database: 'other_db' }, + pool: { min: 0, max: 7 }, + debug: true, + }), + ).toEqual({ + client: 'pg', + connection: { + ...mockConnection, + database: 'other_db', + }, + useNullAsDefault: true, + pool: { min: 0, max: 7 }, + debug: true, + }); + }); + + it('overrides the database from connection string', () => { + const mockConnectionString = createMockConnectionString(); + const mockConnection = createMockConnection(); + + expect( + buildPgDatabaseConfig(createConfig(mockConnectionString), { + connection: { database: 'other_db' }, + }), + ).toEqual({ + client: 'pg', + connection: { + ...mockConnection, + port: '5432', + database: 'other_db', + }, + useNullAsDefault: true, + }); + }); + }); + + describe(getPgConnectionConfig, () => { + it('returns the connection object back', () => { + const mockConnection = createMockConnection(); + const config = createConfig(mockConnection); + + expect(getPgConnectionConfig(config)).toEqual(mockConnection); + }); + + it('does not parse the connection string', () => { + const mockConnection = createMockConnection(); + const config = createConfig(mockConnection); + + expect(getPgConnectionConfig(config, true)).toEqual(mockConnection); + }); + + it('automatically parses the connection string', () => { + const mockConnection = createMockConnection(); + const mockConnectionString = createMockConnectionString(); + const config = createConfig(mockConnectionString); + + expect(getPgConnectionConfig(config)).toEqual({ + ...mockConnection, + port: '5432', + }); + }); + + it('parses the connection string', () => { + const mockConnection = createMockConnection(); + const mockConnectionString = createMockConnectionString(); + const config = createConfig(mockConnectionString); + + expect(getPgConnectionConfig(config, true)).toEqual({ + ...mockConnection, + port: '5432', + }); + }); + }); + + describe(createPgDatabaseClient, () => { + it('creates a postgres knex instance', () => { + expect( + createPgDatabaseClient( + createConfig({ + host: 'acme', + user: 'foo', + password: 'bar', + database: 'foodb', + }), + ), + ).toBeTruthy(); + }); + + it('attempts to read an ssl cert', () => { + expect(() => + createPgDatabaseClient( + createConfig( + 'postgresql://postgres:pass@localhost:5432/dbname?sslrootcert=/path/to/file', + ), + ), + ).toThrowError(/no such file or directory/); + }); + }); + + describe(parsePgConnectionString, () => { + it('parses a connection string uri ', () => { + expect( + parsePgConnectionString( + 'postgresql://postgres:pass@foobar:5432/dbname?ssl=true', + ), + ).toEqual({ + host: 'foobar', + user: 'postgres', + password: 'pass', + port: '5432', + database: 'dbname', + ssl: true, + }); + }); + }); +}); diff --git a/packages/backend-common/src/database/postgres.ts b/packages/backend-common/src/database/postgres.ts new file mode 100644 index 0000000000..03a74156c7 --- /dev/null +++ b/packages/backend-common/src/database/postgres.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import knex, { PgConnectionConfig } from 'knex'; +import { Config } from '@backstage/config'; +import { mergeDatabaseConfig } from './config'; + +/** + * Creates a knex postgres database connection + * + * @param dbConfig The database config + * @param overrides Additional options to merge with the config + */ +export function createPgDatabaseClient( + dbConfig: Config, + overrides?: knex.Config, +) { + const knexConfig = buildPgDatabaseConfig(dbConfig, overrides); + const database = knex(knexConfig); + return database; +} + +/** + * Builds a knex postgres database connection + * + * @param dbConfig The database config + * @param overrides Additional options to merge with the config + */ +export function buildPgDatabaseConfig( + dbConfig: Config, + overrides?: knex.Config, +) { + return mergeDatabaseConfig( + dbConfig.get(), + { + connection: getPgConnectionConfig(dbConfig, !!overrides), + useNullAsDefault: true, + }, + overrides, + ); +} + +/** + * Gets the postgres connection config + * + * @param dbConfig The database config + * @param parseConnectionString Flag to explictly control connection string parsing + */ +export function getPgConnectionConfig( + dbConfig: Config, + parseConnectionString?: boolean, +): PgConnectionConfig | string { + const connection = dbConfig.get('connection') as any; + const isConnectionString = + typeof connection === 'string' || connection instanceof String; + const autoParse = typeof parseConnectionString !== 'boolean'; + + const shouldParseConnectionString = autoParse + ? isConnectionString + : parseConnectionString && isConnectionString; + + return shouldParseConnectionString + ? parsePgConnectionString(connection as string) + : connection; +} + +/** + * Parses a connection string using pg-connection-string + * + * @param connectionString The postgres connection string + */ +export function parsePgConnectionString(connectionString: string) { + const parse = requirePgConnectionString(); + return parse(connectionString); +} + +function requirePgConnectionString() { + try { + return require('pg-connection-string').parse; + } catch (e) { + const message = `Postgres: Install 'pg-connection-string'`; + throw new Error(`${message}\n${e.message}`); + } +} diff --git a/packages/backend-common/src/database/sqlite3.test.ts b/packages/backend-common/src/database/sqlite3.test.ts new file mode 100644 index 0000000000..dbff5ee354 --- /dev/null +++ b/packages/backend-common/src/database/sqlite3.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { + buildSqliteDatabaseConfig, + createSqliteDatabaseClient, +} from './sqlite3'; + +describe('sqlite3', () => { + const createConfig = (connection: any) => + ConfigReader.fromConfigs([ + { + context: '', + data: { + client: 'sqlite3', + connection, + }, + }, + ]); + + describe(buildSqliteDatabaseConfig, () => { + it('buidls a string connection', () => { + expect(buildSqliteDatabaseConfig(createConfig(':memory:'))).toEqual({ + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + }); + + it('builds a filename connection', () => { + expect( + buildSqliteDatabaseConfig( + createConfig({ + filename: '/path/to/foo', + }), + ), + ).toEqual({ + client: 'sqlite3', + connection: { + filename: '/path/to/foo', + }, + useNullAsDefault: true, + }); + }); + + it('replaces the connection with an override', () => { + expect( + buildSqliteDatabaseConfig(createConfig(':memory:'), { + connection: { filename: '/path/to/foo' }, + }), + ).toEqual({ + client: 'sqlite3', + connection: { + filename: '/path/to/foo', + }, + useNullAsDefault: true, + }); + }); + }); + + describe(createSqliteDatabaseClient, () => { + it('creates an in memory knex instance', () => { + expect( + createSqliteDatabaseClient( + createConfig({ + client: 'sqlite3', + connection: ':memory:', + }), + ), + ).toBeTruthy(); + }); + }); +}); diff --git a/packages/backend-common/src/database/sqlite3.ts b/packages/backend-common/src/database/sqlite3.ts new file mode 100644 index 0000000000..7bdc4380a1 --- /dev/null +++ b/packages/backend-common/src/database/sqlite3.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import knex from 'knex'; +import { Config } from '@backstage/config'; +import { mergeDatabaseConfig } from './config'; + +/** + * Creates a knex sqlite3 database connection + * + * @param dbConfig The database config + * @param overrides Additional options to merge with the config + */ +export function createSqliteDatabaseClient( + dbConfig: Config, + overrides?: knex.Config, +) { + const knexConfig = buildSqliteDatabaseConfig(dbConfig, overrides); + const database = knex(knexConfig); + + database.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + + return database; +} + +/** + * Builds a knex sqlite3 connection config + * + * @param dbConfig The database config + * @param overrides Additional options to merge with the config + */ +export function buildSqliteDatabaseConfig( + dbConfig: Config, + overrides?: knex.Config, +) { + return mergeDatabaseConfig( + dbConfig.get(), + { + useNullAsDefault: true, + }, + overrides, + ); +} diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index a3f57ed9df..c527f5fea1 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -15,8 +15,10 @@ */ export * from './config'; +export * from './database'; export * from './errors'; export * from './logging'; export * from './middleware'; export * from './service'; +export * from './paths'; export * from './hot'; diff --git a/packages/backend-common/src/logging/formats.ts b/packages/backend-common/src/logging/formats.ts new file mode 100644 index 0000000000..5f00448744 --- /dev/null +++ b/packages/backend-common/src/logging/formats.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as winston from 'winston'; +import { TransformableInfo } from 'logform'; + +const coloredTemplate = (info: TransformableInfo) => { + const { timestamp, level, message, plugin, service } = info; + const colorizer = winston.format.colorize(); + const prefix = plugin || service; + const timestampColor = colorizer.colorize('timestamp', timestamp); + const prefixColor = colorizer.colorize('prefix', prefix); + + return `${timestampColor} ${prefixColor} ${level} ${message}`; +}; + +export const coloredFormat = winston.format.combine( + winston.format.timestamp(), + winston.format.colorize({ + colors: { timestamp: 'dim', prefix: 'blue' }, + }), + winston.format.printf(coloredTemplate), +); diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index fd2f3a1c8b..306ed23444 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -14,17 +14,14 @@ * limitations under the License. */ import * as winston from 'winston'; +import { coloredFormat } from './formats'; let rootLogger: winston.Logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: process.env.NODE_ENV === 'production' ? winston.format.json() - : winston.format.combine( - winston.format.colorize(), - winston.format.timestamp(), - winston.format.simple(), - ), + : coloredFormat, defaultMeta: { service: 'backstage' }, transports: [ new winston.transports.Console({ diff --git a/packages/backend-common/src/middleware/requestLoggingHandler.ts b/packages/backend-common/src/middleware/requestLoggingHandler.ts index 6604ec245c..061dfbbd25 100644 --- a/packages/backend-common/src/middleware/requestLoggingHandler.ts +++ b/packages/backend-common/src/middleware/requestLoggingHandler.ts @@ -33,7 +33,7 @@ export function requestLoggingHandler(logger?: Logger): RequestHandler { return morgan('combined', { stream: { write(message: String) { - actualLogger.info(message); + actualLogger.info(message.trimRight()); }, }, }); diff --git a/packages/backend-common/src/paths.ts b/packages/backend-common/src/paths.ts new file mode 100644 index 0000000000..402c0e2252 --- /dev/null +++ b/packages/backend-common/src/paths.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +import { resolve as resolvePath } from 'path'; + +/** + * Resolve a path relative to the root of a package directory. + * Additional path arguments are resolved relative to the package dir. + * + * This is particularly useful when you want to access assets shipped with + * your backend plugin package. When doing so, do not forget to include the assets + * in your published package by adding them to `files` in your `package.json`. + */ +export function resolvePackagePath(name: string, ...paths: string[]) { + const req = + typeof __non_webpack_require__ === 'undefined' + ? require + : __non_webpack_require__; + + return resolvePath(req.resolve(`${name}/package.json`), '..', ...paths); +} diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 4a51fb195c..10f9ca4d9b 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -19,7 +19,7 @@ import compression from 'compression'; import cors from 'cors'; import express, { Router } from 'express'; import helmet from 'helmet'; -import { Server } from 'http'; +import * as http from 'http'; import stoppable from 'stoppable'; import { Logger } from 'winston'; import { useHotCleanup } from '../../hot'; @@ -30,16 +30,26 @@ import { requestLoggingHandler, } from '../../middleware'; import { ServiceBuilder } from '../types'; -import { readBaseOptions, readCorsOptions } from './config'; +import { + readBaseOptions, + readCorsOptions, + readHttpsSettings, + HttpsSettings, +} from './config'; +import { createHttpServer, createHttpsServer } from './hostFactory'; +import { metricsHandler } from './metrics'; const DEFAULT_PORT = 7000; -const DEFAULT_HOST = 'localhost'; +// '' is express default, which listens to all interfaces +const DEFAULT_HOST = ''; export class ServiceBuilderImpl implements ServiceBuilder { private port: number | undefined; private host: string | undefined; private logger: Logger | undefined; private corsOptions: cors.CorsOptions | undefined; + private httpsSettings: HttpsSettings | undefined; + private enableMetrics: boolean = true; private routers: [string, Router][]; // Reference to the module where builder is created - needed for hot module // reloading @@ -69,6 +79,14 @@ export class ServiceBuilderImpl implements ServiceBuilder { this.corsOptions = corsOptions; } + const httpsSettings = readHttpsSettings(backendConfig); + if (httpsSettings) { + this.httpsSettings = httpsSettings; + } + + // For now, configuration of metrics is a simple boolean and active by default + this.enableMetrics = backendConfig.getOptionalBoolean('metrics') !== false; + return this; } @@ -87,6 +105,11 @@ export class ServiceBuilderImpl implements ServiceBuilder { return this; } + setHttpsSettings(settings: HttpsSettings): ServiceBuilder { + this.httpsSettings = settings; + return this; + } + enableCors(options: cors.CorsOptions): ServiceBuilder { this.corsOptions = options; return this; @@ -97,16 +120,24 @@ export class ServiceBuilderImpl implements ServiceBuilder { return this; } - start(): Promise { + start(): Promise { const app = express(); - const { port, host, logger, corsOptions } = this.getOptions(); + const { + port, + host, + logger, + corsOptions, + httpsSettings, + } = this.getOptions(); app.use(helmet()); if (corsOptions) { app.use(cors(corsOptions)); } app.use(compression()); - app.use(express.json()); + if (this.enableMetrics) { + app.use(metricsHandler()); + } app.use(requestLoggingHandler()); for (const [root, route] of this.routers) { app.use(root, route); @@ -120,20 +151,24 @@ export class ServiceBuilderImpl implements ServiceBuilder { reject(e); }); - const server = stoppable( - app.listen(port, host, () => { + const server: http.Server = httpsSettings + ? createHttpsServer(app, httpsSettings, logger) + : createHttpServer(app, logger); + + const stoppableServer = stoppable( + server.listen(port, host, () => { logger.info(`Listening on ${host}:${port}`); }), 0, ); useHotCleanup(this.module, () => - server.stop((e: any) => { + stoppableServer.stop((e: any) => { if (e) console.error(e); }), ); - resolve(server); + resolve(stoppableServer); }); } @@ -142,12 +177,14 @@ export class ServiceBuilderImpl implements ServiceBuilder { host: string; logger: Logger; corsOptions?: cors.CorsOptions; + httpsSettings?: HttpsSettings; } { return { port: this.port ?? DEFAULT_PORT, host: this.host ?? DEFAULT_HOST, logger: this.logger ?? getRootLogger(), corsOptions: this.corsOptions, + httpsSettings: this.httpsSettings, }; } } diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index 49f8dc4f04..e257bd4111 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -22,6 +22,41 @@ export type BaseOptions = { listenHost?: string; }; +export type CertificateOptions = { + key?: CertificateKeyOptions; + attributes?: CertificateAttributeOptions; +}; + +export type CertificateKeyOptions = { + size?: number; + algorithm?: string; + days?: number; +}; + +export type CertificateAttributeOptions = { + commonName?: string; +}; + +export type HttpsSettings = { + certificate: CertificateSigningOptions | CertificateReferenceOptions; +}; + +export type CertificateReferenceOptions = { + key: string; + cert: string; +}; + +export type CertificateSigningOptions = { + algorithm: string; + size?: number; + days?: number; + attributes?: CertificateAttributes; +}; + +export type CertificateAttributes = { + commonName?: string; +}; + /** * Reads some base options out of a config object. * @@ -40,6 +75,7 @@ export function readBaseOptions(config: ConfigReader): BaseOptions { if (typeof config.get('listen') === 'string') { // TODO(freben): Expand this to support more addresses and perhaps optional const { host, port } = parseListenAddress(config.getString('listen')); + return removeUnknown({ listenPort: port, listenHost: host, @@ -49,6 +85,7 @@ export function readBaseOptions(config: ConfigReader): BaseOptions { return removeUnknown({ listenPort: config.getOptionalNumber('listen.port'), listenHost: config.getOptionalString('listen.host'), + baseUrl: config.getOptionalString('baseUrl'), }); } @@ -86,6 +123,39 @@ export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { }); } +/** + * Attempts to read a https settings object from the root of a config object. + * + * @param config The root of a backend config object + * @returns A https settings object, or undefined if not specified + * + * @example + * ```json + * { + * https: { + * certificate: ... + * } + * } + * ``` + */ +export function readHttpsSettings( + config: ConfigReader, +): HttpsSettings | undefined { + const cc = config.getOptionalConfig('https'); + + if (!cc) { + return undefined; + } + + const certificateConfig = cc.get('certificate'); + + const cfg = { + certificate: certificateConfig, + }; + + return removeUnknown(cfg as HttpsSettings); +} + function getOptionalStringOrStrings( config: ConfigReader, key: string, diff --git a/packages/backend-common/src/service/lib/hostFactory.ts b/packages/backend-common/src/service/lib/hostFactory.ts new file mode 100644 index 0000000000..8fab2fd4ab --- /dev/null +++ b/packages/backend-common/src/service/lib/hostFactory.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import express from 'express'; +import * as http from 'http'; +import * as https from 'https'; +import { Logger } from 'winston'; +import { HttpsSettings } from './config'; + +/** + * Creates a Http server instance based on an Express application. + * + * @param app The Express application object + * @param logger Optional Winston logger object + * @returns A Http server instance + * + */ +export function createHttpServer( + app: express.Express, + logger?: Logger, +): http.Server { + logger?.info('Initializing http server'); + + return http.createServer(app); +} + +/** + * Creates a Https server instance based on an Express application. + * + * @param app The Express application object + * @param httpsSettings HttpsSettings for self-signed certificate generation + * @param logger Optional Winston logger object + * @returns A Https server instance + * + */ +export function createHttpsServer( + app: express.Express, + httpsSettings: HttpsSettings, + logger?: Logger, +): http.Server { + logger?.info('Initializing https server'); + + const credentials: { key: string; cert: string } = { + key: '', + cert: '', + }; + + const signingOptions: any = httpsSettings?.certificate; + + if (signingOptions?.algorithm !== undefined) { + logger?.info('Generating self-signed certificate with attributes'); + + const certificateAttributes: Array = Object.entries( + signingOptions.attributes, + ).map(([name, value]) => ({ name, value })); + + // TODO: Create a type def for selfsigned. + const signatures = require('selfsigned').generate(certificateAttributes, { + algorithm: signingOptions?.algorithm, + keySize: signingOptions?.size || 2048, + days: signingOptions?.days || 30, + }); + + logger?.info('Bootstrapping self-signed certificate'); + + credentials.key = signatures.private; + credentials.cert = signatures.cert; + } else { + logger?.info('Bootstrapping cert from config'); + + credentials.key = signingOptions?.key; + credentials.cert = signingOptions?.cert; + } + + if (credentials.key === '' || credentials.cert === '') { + throw new Error('Invalid credentials'); + } + + return https.createServer(credentials, app) as http.Server; +} diff --git a/packages/backend-common/src/service/lib/metrics.ts b/packages/backend-common/src/service/lib/metrics.ts new file mode 100644 index 0000000000..d7b544765b --- /dev/null +++ b/packages/backend-common/src/service/lib/metrics.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import prom from 'prom-client'; +import promBundle from 'express-prom-bundle'; +import { RequestHandler } from 'express'; + +/** + * Adds a /metrics endpoint, register default runtime metrics and instrument the router. + */ +export function metricsHandler(): RequestHandler { + // We can only initialize the metrics once and have to clean them up between hot reloads + prom.register.clear(); + + return promBundle({ + includeMethod: true, + includePath: true, + // Using includePath alone is problematic, as it will include path labels with high + // cardinality (e.g. path params). Instead we would have to template them. However, this + // is difficult, as every backend plugin might use different routes. Instead we only take + // the first directory of the path, to have at least an idea how each plugin performs: + normalizePath: [['^/([^/]*)/.*', '/$1']], + promClient: { collectDefaultMetrics: {} }, + }); +} diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts index 070794969f..d389f4b5ff 100644 --- a/packages/backend-common/src/service/types.ts +++ b/packages/backend-common/src/service/types.ts @@ -19,6 +19,7 @@ import cors from 'cors'; import { Router, RequestHandler } from 'express'; import { Server } from 'http'; import { Logger } from 'winston'; +import { HttpsSettings } from './lib/config'; export type ServiceBuilder = { /** @@ -39,6 +40,15 @@ export type ServiceBuilder = { */ setPort(port: number): ServiceBuilder; + /** + * Sets the host to listen on. + * + * '' is express default, which listens to all interfaces. + * + * @param host The host to listen on + */ + setHost(host: string): ServiceBuilder; + /** * Sets the logger to use for service-specific logging. * @@ -58,6 +68,15 @@ export type ServiceBuilder = { */ enableCors(options: cors.CorsOptions): ServiceBuilder; + /** + * Configure self-signed certificate generation options. + * + * If this method is not called, the resulting service will use sensible defaults + * + * @param options Standard certificate options + */ + setHttpsSettings(settings: HttpsSettings): ServiceBuilder; + /** * Adds a router (similar to the express .use call) to the service. * diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index 8d17821101..a7bd814b19 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -2,10 +2,15 @@ FROM node:12 WORKDIR /usr/src/app +# Copy repo skeleton first, to avoid unnecessary docker cache invalidation. +# The skeleton contains the package.json of each package in the monorepo, +# and along with yarn.lock and the root package.json, that's enough to run yarn install. +ADD yarn.lock package.json skeleton.tar ./ + +RUN yarn install --frozen-lockfile --production + # This will copy the contents of the dist-workspace when running the build-image command. # Do not use this Dockerfile outside of that command, as it will copy in the source code instead. COPY . . -RUN yarn install --frozen-lockfile --production - CMD ["node", "packages/backend"] diff --git a/packages/backend/README.md b/packages/backend/README.md index 90c70912f0..c45a0d28d9 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -37,26 +37,19 @@ yarn start Substitute `x` for actual values, or leave them as dummy values just to try out the backend without using the auth or sentry features. +You can also, instead of using dummy values for a huge number of environment variables, remove those config directly from app-config.yaml file located in the root folder. The backend starts up on port 7000 per default. ## Populating The Catalog -If you want to use the catalog functionality, you need to add so called locations -to the backend. These are places where the backend can find some entity descriptor -data to consume and serve. +If you want to use the catalog functionality, you need to add so called +locations to the backend. These are places where the backend can find some +entity descriptor data to consume and serve. For more information, see +[Software Catalog Overview - Adding Components to the Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview#adding-components-to-the-catalog). -To get started, you can issue the following after starting the backend, from inside -the `plugins/catalog-backend` directory: - -```bash -yarn mock-data -``` - -You should then start seeing data on `localhost:7000/catalog/entities`. - -The catalog currently runs in-memory only, so feel free to try it out, but it will -need to be re-populated on next startup. +For convenience we already include some statically configured example locations +in `app-config.yaml` under `catalog.locations`. For local development you can override these in your own `app-config.local.yaml`. ## Authentication diff --git a/packages/backend/package.json b/packages/backend/package.json index 6e1bcdaf69..c255f65015 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "dist/index.cjs.js", "types": "src/index.ts", "private": true, @@ -10,7 +10,7 @@ }, "scripts": { "build": "backstage-cli backend:build", - "build-image": "backstage-cli backend:build-image example-backend", + "build-image": "backstage-cli backend:build-image --build --tag example-backend", "start": "backstage-cli backend:dev", "lint": "backstage-cli lint", "test": "backstage-cli test", @@ -18,31 +18,35 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.18", - "@backstage/catalog-model": "^0.1.1-alpha.18", - "@backstage/config": "^0.1.1-alpha.18", - "@backstage/plugin-auth-backend": "^0.1.1-alpha.18", - "@backstage/plugin-catalog-backend": "^0.1.1-alpha.18", - "@backstage/plugin-graphql-backend": "^0.1.1-alpha.18", - "@backstage/plugin-identity-backend": "^0.1.1-alpha.18", - "@backstage/plugin-proxy-backend": "^0.1.1-alpha.18", - "@backstage/plugin-rollbar-backend": "^0.1.1-alpha.18", - "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.18", - "@backstage/plugin-sentry-backend": "^0.1.1-alpha.18", - "@backstage/plugin-techdocs-backend": "^0.1.1-alpha.18", + "@backstage/backend-common": "^0.1.1-alpha.21", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/config": "^0.1.1-alpha.21", + "@backstage/plugin-app-backend": "^0.1.1-alpha.21", + "@backstage/plugin-auth-backend": "^0.1.1-alpha.21", + "@backstage/plugin-catalog-backend": "^0.1.1-alpha.21", + "@backstage/plugin-graphql-backend": "^0.1.1-alpha.21", + "@backstage/plugin-identity-backend": "^0.1.1-alpha.21", + "@backstage/plugin-proxy-backend": "^0.1.1-alpha.21", + "@backstage/plugin-rollbar-backend": "^0.1.1-alpha.21", + "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.21", + "@backstage/plugin-sentry-backend": "^0.1.1-alpha.21", + "@backstage/plugin-techdocs-backend": "^0.1.1-alpha.21", + "@gitbeaker/node": "^23.5.0", "@octokit/rest": "^18.0.0", "dockerode": "^3.2.0", + "example-app": "^0.1.1-alpha.21", "express": "^4.17.1", "knex": "^0.21.1", "pg": "^8.3.0", + "pg-connection-string": "^2.3.0", "sqlite3": "^4.2.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", "@types/dockerode": "^2.5.32", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", - "@types/helmet": "^0.0.47" + "@types/helmet": "^0.0.48" } } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 31b59b3c6b..ab32822769 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -23,13 +23,13 @@ */ import { + createDatabaseClient, createServiceBuilder, loadBackendConfig, getRootLogger, useHotMemoize, } from '@backstage/backend-common'; import { ConfigReader, AppConfig } from '@backstage/config'; -import knex, { PgConnectionConfig } from 'knex'; import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; @@ -40,6 +40,7 @@ import sentry from './plugins/sentry'; import proxy from './plugins/proxy'; import techdocs from './plugins/techdocs'; import graphql from './plugins/graphql'; +import app from './plugins/app'; import { PluginEnvironment } from './types'; function makeCreateEnv(loadedConfigs: AppConfig[]) { @@ -47,39 +48,14 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { return (plugin: string): PluginEnvironment => { const logger = getRootLogger().child({ type: 'plugin', plugin }); - // Supported DBs are sqlite and postgres - const isPg = [ - 'POSTGRES_USER', - 'POSTGRES_HOST', - 'POSTGRES_PASSWORD', - ].every(key => config.getOptional(`backend.${key}`)); - - let knexConfig; - - if (isPg) { - knexConfig = { - client: 'pg', - useNullAsDefault: true, + const database = createDatabaseClient( + config.getConfig('backend.database'), + { connection: { - port: config.getOptionalNumber('backend.POSTGRES_PORT'), - host: config.getString('backend.POSTGRES_HOST'), - user: config.getString('backend.POSTGRES_USER'), - password: config.getString('backend.POSTGRES_PASSWORD'), database: `backstage_plugin_${plugin}`, - } as PgConnectionConfig, - }; - } else { - knexConfig = { - client: 'sqlite3', - connection: ':memory:', - useNullAsDefault: true, - }; - } - - const database = knex(knexConfig); - database.client.pool.on('createSuccess', (_eventId: any, resource: any) => { - resource.run('PRAGMA foreign_keys = ON', () => {}); - }); + }, + }, + ); return { logger, database, config }; }; } @@ -98,6 +74,7 @@ async function main() { const sentryEnv = useHotMemoize(module, () => createEnv('sentry')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); + const appEnv = useHotMemoize(module, () => createEnv('app')); const service = createServiceBuilder(module) .loadConfig(configReader) @@ -109,8 +86,9 @@ async function main() { .addRouter('/auth', await auth(authEnv)) .addRouter('/identity', await identity(identityEnv)) .addRouter('/techdocs', await techdocs(techdocsEnv)) - .addRouter('/proxy', await proxy(proxyEnv)) - .addRouter('/graphql', await graphql(graphqlEnv)); + .addRouter('/proxy', await proxy(proxyEnv, '/proxy')) + .addRouter('/graphql', await graphql(graphqlEnv)) + .addRouter('', await app(appEnv)); await service.start().catch(err => { console.log(err); @@ -120,6 +98,6 @@ async function main() { module.hot?.accept(); main().catch(error => { - console.error(`Backend failed to start up, ${error}`); + console.error('Backend failed to start up', error); process.exit(1); }); diff --git a/packages/cli/e2e-test/.eslintrc.js b/packages/backend/src/plugins/app.ts similarity index 68% rename from packages/cli/e2e-test/.eslintrc.js rename to packages/backend/src/plugins/app.ts index 274c7426b8..c9f7c0622a 100644 --- a/packages/cli/e2e-test/.eslintrc.js +++ b/packages/backend/src/plugins/app.ts @@ -14,16 +14,12 @@ * limitations under the License. */ -module.exports = { - rules: { - 'import/no-extraneous-dependencies': [ - 'error', - { - devDependencies: true, - optionalDependencies: true, - peerDependencies: true, - bundledDependencies: true, - }, - ], - }, -}; +import { createRouter } from '@backstage/plugin-app-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ logger }: PluginEnvironment) { + return await createRouter({ + logger, + appPackageName: 'example-app', + }); +} diff --git a/packages/backend/src/plugins/proxy.ts b/packages/backend/src/plugins/proxy.ts index 4964de130e..e96acf69d3 100644 --- a/packages/backend/src/plugins/proxy.ts +++ b/packages/backend/src/plugins/proxy.ts @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + // @ts-ignore import { createRouter } from '@backstage/plugin-proxy-backend'; import { PluginEnvironment } from '../types'; -export default async function createPlugin({ - logger, - config, -}: PluginEnvironment) { - return await createRouter({ logger, config }); +export default async function createPlugin( + { logger, config }: PluginEnvironment, + pathPrefix: string, +) { + return await createRouter({ logger, config, pathPrefix }); } diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index a9e5f185ec..c4b7e22142 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -19,16 +19,24 @@ import { createRouter, FilePreparer, GithubPreparer, + GitlabPreparer, Preparers, + Publishers, GithubPublisher, + GitlabPublisher, CreateReactAppTemplater, Templaters, + RepoVisilityOptions, } from '@backstage/plugin-scaffolder-backend'; import { Octokit } from '@octokit/rest'; +import { Gitlab } from '@gitbeaker/node'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; -export default async function createPlugin({ logger }: PluginEnvironment) { +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); @@ -37,19 +45,48 @@ export default async function createPlugin({ logger }: PluginEnvironment) { const filePreparer = new FilePreparer(); const githubPreparer = new GithubPreparer(); + const gitlabPreparer = new GitlabPreparer(config); const preparers = new Preparers(); preparers.register('file', filePreparer); preparers.register('github', githubPreparer); + preparers.register('gitlab', gitlabPreparer); + preparers.register('gitlab/api', gitlabPreparer); - const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN }); - const publisher = new GithubPublisher({ client: githubClient }); + const publishers = new Publishers(); + + const githubToken = config.getString('scaffolder.github.token'); + const repoVisibility = config.getString( + 'scaffolder.github.visibility', + ) as RepoVisilityOptions; + + const githubClient = new Octokit({ auth: githubToken }); + const githubPublisher = new GithubPublisher({ + client: githubClient, + token: githubToken, + repoVisibility, + }); + publishers.register('file', githubPublisher); + publishers.register('github', githubPublisher); + + const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab.api'); + + if (gitLabConfig) { + const gitLabToken = gitLabConfig.getString('token'); + const gitLabClient = new Gitlab({ + host: gitLabConfig.getOptionalString('baseUrl'), + token: gitLabToken, + }); + const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); + publishers.register('gitlab', gitLabPublisher); + publishers.register('gitlab/api', gitLabPublisher); + } const dockerClient = new Docker(); return await createRouter({ preparers, templaters, - publisher, + publishers, logger, dockerClient, }); diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index 812a56cd9f..7364cc4d83 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -20,22 +20,27 @@ import { Preparers, Generators, LocalPublish, - TechdocsGenerator + TechdocsGenerator, + GithubPreparer, } from '@backstage/plugin-techdocs-backend'; import { PluginEnvironment } from '../types'; import Docker from 'dockerode'; -export default async function createPlugin({ logger, config }: PluginEnvironment) { +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { const generators = new Generators(); - const techdocsGenerator = new TechdocsGenerator(); + const techdocsGenerator = new TechdocsGenerator(logger); generators.register('techdocs', techdocsGenerator); - const directoryPreparer = new DirectoryPreparer(); const preparers = new Preparers(); - + const githubPreparer = new GithubPreparer(logger); + const directoryPreparer = new DirectoryPreparer(logger); preparers.register('dir', directoryPreparer); + preparers.register('github', githubPreparer); - const publisher = new LocalPublish(); + const publisher = new LocalPublish(logger); const dockerClient = new Docker(); diff --git a/packages/catalog-model/examples/all-apis.yaml b/packages/catalog-model/examples/all-apis.yaml new file mode 100644 index 0000000000..33b000d1ae --- /dev/null +++ b/packages/catalog-model/examples/all-apis.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-apis + description: A collection of all Backstage example APIs +spec: + type: github + targets: + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/hello-world-api.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/streetlights-api.yaml diff --git a/packages/catalog-model/examples/all-components.yaml b/packages/catalog-model/examples/all-components.yaml new file mode 100644 index 0000000000..cad4eee044 --- /dev/null +++ b/packages/catalog-model/examples/all-components.yaml @@ -0,0 +1,16 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-components + description: A collection of all Backstage example components +spec: + type: github + targets: + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-order-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/podcast-api-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/queue-proxy-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/searcher-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-lib-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/www-artist-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/shuffle-api-component.yaml diff --git a/packages/catalog-model/examples/artist-lookup-component.yaml b/packages/catalog-model/examples/artist-lookup-component.yaml index 448dd13fa3..79dd3bccf6 100644 --- a/packages/catalog-model/examples/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/artist-lookup-component.yaml @@ -3,6 +3,9 @@ kind: Component metadata: name: artist-lookup description: Artist Lookup + tags: + - java + - data spec: type: service lifecycle: experimental diff --git a/packages/catalog-model/examples/hello-world-api.yaml b/packages/catalog-model/examples/hello-world-api.yaml index 298b0cb835..659c48adfb 100644 --- a/packages/catalog-model/examples/hello-world-api.yaml +++ b/packages/catalog-model/examples/hello-world-api.yaml @@ -5,6 +5,8 @@ metadata: description: Hello World example for gRPC spec: type: grpc + lifecycle: deprecated + owner: grpc@example.com definition: | // Copyright 2015 gRPC authors. // diff --git a/packages/catalog-model/examples/petstore-api.yaml b/packages/catalog-model/examples/petstore-api.yaml index 01953a4a97..c8ce576cce 100644 --- a/packages/catalog-model/examples/petstore-api.yaml +++ b/packages/catalog-model/examples/petstore-api.yaml @@ -3,8 +3,13 @@ kind: API metadata: name: petstore description: The petstore API + tags: + - store + - rest spec: type: openapi + lifecycle: experimental + owner: pets@example.com definition: | openapi: "3.0.0" info: diff --git a/packages/catalog-model/examples/petstore-component.yaml b/packages/catalog-model/examples/petstore-component.yaml index 5cf0d1f270..7e2093ad09 100644 --- a/packages/catalog-model/examples/petstore-component.yaml +++ b/packages/catalog-model/examples/petstore-component.yaml @@ -7,7 +7,7 @@ spec: type: service lifecycle: experimental owner: pets@example.com - implementedApis: + implementsApis: - petstore - streetlights - hello-world diff --git a/packages/catalog-model/examples/playback-order-component.yaml b/packages/catalog-model/examples/playback-order-component.yaml index 4f93b65edf..3e46953928 100644 --- a/packages/catalog-model/examples/playback-order-component.yaml +++ b/packages/catalog-model/examples/playback-order-component.yaml @@ -3,6 +3,9 @@ kind: Component metadata: name: playback-order description: Playback Order + tags: + - java + - playback spec: type: service lifecycle: production diff --git a/packages/catalog-model/examples/podcast-api-component.yaml b/packages/catalog-model/examples/podcast-api-component.yaml index 19f9a3d4de..c1b1c9281c 100644 --- a/packages/catalog-model/examples/podcast-api-component.yaml +++ b/packages/catalog-model/examples/podcast-api-component.yaml @@ -3,6 +3,8 @@ kind: Component metadata: name: podcast-api description: Podcast API + tags: + - java spec: type: service lifecycle: experimental diff --git a/packages/catalog-model/examples/queue-proxy-component.yaml b/packages/catalog-model/examples/queue-proxy-component.yaml index a9f41b8776..c9b130db52 100644 --- a/packages/catalog-model/examples/queue-proxy-component.yaml +++ b/packages/catalog-model/examples/queue-proxy-component.yaml @@ -3,6 +3,9 @@ kind: Component metadata: name: queue-proxy description: Queue Proxy + tags: + - go + - website spec: type: website lifecycle: production diff --git a/packages/catalog-model/examples/searcher-component.yaml b/packages/catalog-model/examples/searcher-component.yaml index 33d765117c..042fcb24a9 100644 --- a/packages/catalog-model/examples/searcher-component.yaml +++ b/packages/catalog-model/examples/searcher-component.yaml @@ -3,6 +3,8 @@ kind: Component metadata: name: searcher description: Searcher + tags: + - go spec: type: service lifecycle: production diff --git a/packages/catalog-model/examples/shuffle-api-component.yaml b/packages/catalog-model/examples/shuffle-api-component.yaml index 275c48d6ba..1f9de46d4e 100644 --- a/packages/catalog-model/examples/shuffle-api-component.yaml +++ b/packages/catalog-model/examples/shuffle-api-component.yaml @@ -3,6 +3,8 @@ kind: Component metadata: name: shuffle-api description: Shuffle API + tags: + - go spec: type: service lifecycle: production diff --git a/packages/catalog-model/examples/streetlights-api.yaml b/packages/catalog-model/examples/streetlights-api.yaml index 69a6d101cb..d53b05fc2d 100644 --- a/packages/catalog-model/examples/streetlights-api.yaml +++ b/packages/catalog-model/examples/streetlights-api.yaml @@ -3,8 +3,12 @@ kind: API metadata: name: streetlights description: The Smartylighting Streetlights API allows you to remotely manage the city lights. + tags: + - mqtt spec: type: asyncapi + lifecycle: production + owner: streetlights@example.com definition: | asyncapi: 2.0.0 info: diff --git a/packages/catalog-model/examples/swapi-graphql.yaml b/packages/catalog-model/examples/swapi-graphql.yaml new file mode 100644 index 0000000000..152d9c0afa --- /dev/null +++ b/packages/catalog-model/examples/swapi-graphql.yaml @@ -0,0 +1,1174 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: starwars-graphql + description: SWAPI GraphQL Schema +spec: + type: graphql + definition: | + schema { + query: Root + } + + """A single film.""" + type Film implements Node { + """The title of this film.""" + title: String + + """The episode number of this film.""" + episodeID: Int + + """The opening paragraphs at the beginning of this film.""" + openingCrawl: String + + """The name of the director of this film.""" + director: String + + """The name(s) of the producer(s) of this film.""" + producers: [String] + + """The ISO 8601 date format of film release at original creator country.""" + releaseDate: String + speciesConnection(after: String, first: Int, before: String, last: Int): FilmSpeciesConnection + starshipConnection(after: String, first: Int, before: String, last: Int): FilmStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): FilmVehiclesConnection + characterConnection(after: String, first: Int, before: String, last: Int): FilmCharactersConnection + planetConnection(after: String, first: Int, before: String, last: Int): FilmPlanetsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type FilmCharactersConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmCharactersEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + characters: [Person] + } + + """An edge in a connection.""" + type FilmCharactersEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmPlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmPlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] + } + + """An edge in a connection.""" + type FilmPlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type FilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmSpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmSpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] + } + + """An edge in a connection.""" + type FilmSpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] + } + + """An edge in a connection.""" + type FilmStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] + } + + """An edge in a connection.""" + type FilmVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! + } + + """An object with an ID""" + interface Node { + """The id of the object.""" + id: ID! + } + + """Information about pagination in a connection.""" + type PageInfo { + """When paginating forwards, are there more items?""" + hasNextPage: Boolean! + + """When paginating backwards, are there more items?""" + hasPreviousPage: Boolean! + + """When paginating backwards, the cursor to continue.""" + startCursor: String + + """When paginating forwards, the cursor to continue.""" + endCursor: String + } + + """A connection to a list of items.""" + type PeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] + } + + """An edge in a connection.""" + type PeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """An individual person or character within the Star Wars universe.""" + type Person implements Node { + """The name of this person.""" + name: String + + """ + The birth year of the person, using the in-universe standard of BBY or ABY - + Before the Battle of Yavin or After the Battle of Yavin. The Battle of Yavin is + a battle that occurs at the end of Star Wars episode IV: A New Hope. + """ + birthYear: String + + """ + The eye color of this person. Will be "unknown" if not known or "n/a" if the + person does not have an eye. + """ + eyeColor: String + + """ + The gender of this person. Either "Male", "Female" or "unknown", + "n/a" if the person does not have a gender. + """ + gender: String + + """ + The hair color of this person. Will be "unknown" if not known or "n/a" if the + person does not have hair. + """ + hairColor: String + + """The height of the person in centimeters.""" + height: Int + + """The mass of the person in kilograms.""" + mass: Float + + """The skin color of this person.""" + skinColor: String + + """A planet that this person was born on or inhabits.""" + homeworld: Planet + filmConnection(after: String, first: Int, before: String, last: Int): PersonFilmsConnection + + """The species that this person belongs to, or null if unknown.""" + species: Species + starshipConnection(after: String, first: Int, before: String, last: Int): PersonStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): PersonVehiclesConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type PersonFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type PersonFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PersonStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] + } + + """An edge in a connection.""" + type PersonStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PersonVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] + } + + """An edge in a connection.""" + type PersonVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! + } + + """ + A large mass, planet or planetoid in the Star Wars Universe, at the time of + 0 ABY. + """ + type Planet implements Node { + """The name of this planet.""" + name: String + + """The diameter of this planet in kilometers.""" + diameter: Int + + """ + The number of standard hours it takes for this planet to complete a single + rotation on its axis. + """ + rotationPeriod: Int + + """ + The number of standard days it takes for this planet to complete a single orbit + of its local star. + """ + orbitalPeriod: Int + + """ + A number denoting the gravity of this planet, where "1" is normal or 1 standard + G. "2" is twice or 2 standard Gs. "0.5" is half or 0.5 standard Gs. + """ + gravity: String + + """The average population of sentient beings inhabiting this planet.""" + population: Float + + """The climates of this planet.""" + climates: [String] + + """The terrains of this planet.""" + terrains: [String] + + """ + The percentage of the planet surface that is naturally occuring water or bodies + of water. + """ + surfaceWater: Float + residentConnection(after: String, first: Int, before: String, last: Int): PlanetResidentsConnection + filmConnection(after: String, first: Int, before: String, last: Int): PlanetFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type PlanetFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type PlanetFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PlanetResidentsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetResidentsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + residents: [Person] + } + + """An edge in a connection.""" + type PlanetResidentsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] + } + + """An edge in a connection.""" + type PlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! + } + + type Root { + allFilms(after: String, first: Int, before: String, last: Int): FilmsConnection + film(id: ID, filmID: ID): Film + allPeople(after: String, first: Int, before: String, last: Int): PeopleConnection + person(id: ID, personID: ID): Person + allPlanets(after: String, first: Int, before: String, last: Int): PlanetsConnection + planet(id: ID, planetID: ID): Planet + allSpecies(after: String, first: Int, before: String, last: Int): SpeciesConnection + species(id: ID, speciesID: ID): Species + allStarships(after: String, first: Int, before: String, last: Int): StarshipsConnection + starship(id: ID, starshipID: ID): Starship + allVehicles(after: String, first: Int, before: String, last: Int): VehiclesConnection + vehicle(id: ID, vehicleID: ID): Vehicle + + """Fetches an object given its ID""" + node( + """The ID of an object""" + id: ID! + ): Node + } + + """A type of person or character within the Star Wars Universe.""" + type Species implements Node { + """The name of this species.""" + name: String + + """The classification of this species, such as "mammal" or "reptile".""" + classification: String + + """The designation of this species, such as "sentient".""" + designation: String + + """The average height of this species in centimeters.""" + averageHeight: Float + + """The average lifespan of this species in years, null if unknown.""" + averageLifespan: Int + + """ + Common eye colors for this species, null if this species does not typically + have eyes. + """ + eyeColors: [String] + + """ + Common hair colors for this species, null if this species does not typically + have hair. + """ + hairColors: [String] + + """ + Common skin colors for this species, null if this species does not typically + have skin. + """ + skinColors: [String] + + """The language commonly spoken by this species.""" + language: String + + """A planet that this species originates from.""" + homeworld: Planet + personConnection(after: String, first: Int, before: String, last: Int): SpeciesPeopleConnection + filmConnection(after: String, first: Int, before: String, last: Int): SpeciesFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type SpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] + } + + """An edge in a connection.""" + type SpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type SpeciesFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type SpeciesFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type SpeciesPeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesPeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] + } + + """An edge in a connection.""" + type SpeciesPeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A single transport craft that has hyperdrive capability.""" + type Starship implements Node { + """The name of this starship. The common name, such as "Death Star".""" + name: String + + """ + The model or official name of this starship. Such as "T-65 X-wing" or "DS-1 + Orbital Battle Station". + """ + model: String + + """ + The class of this starship, such as "Starfighter" or "Deep Space Mobile + Battlestation" + """ + starshipClass: String + + """The manufacturers of this starship.""" + manufacturers: [String] + + """The cost of this starship new, in galactic credits.""" + costInCredits: Float + + """The length of this starship in meters.""" + length: Float + + """The number of personnel needed to run or pilot this starship.""" + crew: String + + """The number of non-essential people this starship can transport.""" + passengers: String + + """ + The maximum speed of this starship in atmosphere. null if this starship is + incapable of atmosphering flight. + """ + maxAtmospheringSpeed: Int + + """The class of this starships hyperdrive.""" + hyperdriveRating: Float + + """ + The Maximum number of Megalights this starship can travel in a standard hour. + A "Megalight" is a standard unit of distance and has never been defined before + within the Star Wars universe. This figure is only really useful for measuring + the difference in speed of starships. We can assume it is similar to AU, the + distance between our Sun (Sol) and Earth. + """ + MGLT: Int + + """The maximum number of kilograms that this starship can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this starship can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): StarshipPilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): StarshipFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type StarshipFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type StarshipFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type StarshipPilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipPilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] + } + + """An edge in a connection.""" + type StarshipPilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type StarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] + } + + """An edge in a connection.""" + type StarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! + } + + """A single transport craft that does not have hyperdrive capability""" + type Vehicle implements Node { + """ + The name of this vehicle. The common name, such as "Sand Crawler" or "Speeder + bike". + """ + name: String + + """ + The model or official name of this vehicle. Such as "All-Terrain Attack + Transport". + """ + model: String + + """The class of this vehicle, such as "Wheeled" or "Repulsorcraft".""" + vehicleClass: String + + """The manufacturers of this vehicle.""" + manufacturers: [String] + + """The cost of this vehicle new, in Galactic Credits.""" + costInCredits: Float + + """The length of this vehicle in meters.""" + length: Float + + """The number of personnel needed to run or pilot this vehicle.""" + crew: String + + """The number of non-essential people this vehicle can transport.""" + passengers: String + + """The maximum speed of this vehicle in atmosphere.""" + maxAtmospheringSpeed: Int + + """The maximum number of kilograms that this vehicle can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this vehicle can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): VehiclePilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): VehicleFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type VehicleFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehicleFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type VehicleFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type VehiclePilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclePilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] + } + + """An edge in a connection.""" + type VehiclePilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type VehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] + } + + """An edge in a connection.""" + type VehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! + } diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 248f2765e4..0d18125d43 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,7 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.18", + "@backstage/config": "^0.1.1-alpha.21", "@types/json-schema": "^7.0.5", "@types/yup": "^0.28.2", "json-schema": "^0.2.5", @@ -29,7 +29,7 @@ "yup": "^0.29.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts index 4f245da8b7..7f323043b1 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -113,6 +113,12 @@ export type EntityMeta = JsonObject & { * entity. */ annotations?: Record; + + /** + * A list of single-valued strings, to for example classify catalog entities in + * various ways. + */ + tags?: string[]; }; /** diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts index 80cf70fbab..edf04a64cc 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts @@ -35,6 +35,9 @@ describe('FieldFormatEntityPolicy', () => { backstage.io/custom: ValueStuff annotations: example.com/bindings: are-secret + tags: + - java + - data-service spec: custom: stuff `); @@ -102,4 +105,9 @@ describe('FieldFormatEntityPolicy', () => { data.metadata.annotations.a = 7; await expect(policy.enforce(data)).rejects.toThrow(/annotation.*7/i); }); + + it('rejects bad tag value', async () => { + data.metadata.tags.push('Hello World'); + await expect(policy.enforce(data)).rejects.toThrow(/tags.*"Hello World"/i); + }); }); diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts index 4ccd8ec711..20a3724079 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts @@ -83,6 +83,12 @@ export class FieldFormatEntityPolicy implements EntityPolicy { require(`annotations.${k}`, v, this.validators.isValidAnnotationValue); } + const tags = entity.metadata.tags ?? []; + + for (let i = 0; i < tags.length; ++i) { + require(`tags.${i}`, tags[i], this.validators.isValidTag); + } + return entity; } } diff --git a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts index 04acd58686..5d4e60a1a3 100644 --- a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts @@ -35,6 +35,9 @@ describe('ReservedFieldsEntityPolicy', () => { backstage.io/custom: ValueStuff annotations: example.com/bindings: are-secret + tags: + - java + - data spec: custom: stuff `); diff --git a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts index bbe374e811..edb63cf05c 100644 --- a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts @@ -29,6 +29,7 @@ const DEFAULT_RESERVED_ENTITY_FIELDS: string[] = [ 'metadata.description', 'metadata.labels', 'metadata.annotations', + 'metadata.tags', // The below items are known to appear in core kinds, and therefore should // not be appearing in metadata (which would indicate that the user made a // mistake in where to place them). diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts index 2113772af4..d63ba49dbe 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts @@ -36,6 +36,9 @@ describe('SchemaValidEntityPolicy', () => { backstage.io/custom: ValueStuff annotations: example.com/bindings: are-secret + tags: + - java + - data spec: custom: stuff `); @@ -190,6 +193,11 @@ describe('SchemaValidEntityPolicy', () => { await expect(policy.enforce(data)).rejects.toThrow(/annotations/); }); + it('rejects bad tags type', async () => { + data.metadata.tags = 7; + await expect(policy.enforce(data)).rejects.toThrow(/tags/); + }); + // // spec // diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts index e901e82d8b..a6d376b4ac 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts @@ -31,6 +31,7 @@ const DEFAULT_ENTITY_SCHEMA = yup.object({ description: yup.string().notRequired(), labels: yup.object>().notRequired(), annotations: yup.object>().notRequired(), + tags: yup.array().notRequired(), }) .required(), spec: yup.object({}).notRequired(), diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts index 48de569213..35a1c59ef8 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts @@ -33,6 +33,8 @@ describe('ApiV1alpha1Policy', () => { }, spec: { type: 'openapi', + lifecycle: 'production', + owner: 'me', definition: ` openapi: "3.0.0" info: @@ -109,6 +111,36 @@ components: await expect(policy.enforce(entity)).rejects.toThrow(/type/); }); + it('rejects missing lifecycle', async () => { + delete (entity as any).spec.lifecycle; + await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + }); + + it('rejects wrong lifecycle', async () => { + (entity as any).spec.lifecycle = 7; + await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + }); + + it('rejects empty lifecycle', async () => { + (entity as any).spec.lifecycle = ''; + await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + }); + + it('rejects missing owner', async () => { + delete (entity as any).spec.owner; + await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + }); + + it('rejects wrong owner', async () => { + (entity as any).spec.owner = 7; + await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + }); + + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + }); + it('rejects missing definition', async () => { delete (entity as any).spec.definition; await expect(policy.enforce(entity)).rejects.toThrow(/definition/); diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 59e16df9a7..972f6df96d 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -26,6 +26,8 @@ export interface ApiEntityV1alpha1 extends Entity { kind: typeof KIND; spec: { type: string; + lifecycle: string; + owner: string; definition: string; }; } @@ -40,6 +42,8 @@ export class ApiEntityV1alpha1Policy implements EntityPolicy { spec: yup .object({ type: yup.string().required().min(1), + lifecycle: yup.string().required().min(1), + owner: yup.string().required().min(1), definition: yup.string().required().min(1), }) .required(), diff --git a/packages/catalog-model/src/validation/makeValidator.ts b/packages/catalog-model/src/validation/makeValidator.ts index 7ca01365e0..3602c01a63 100644 --- a/packages/catalog-model/src/validation/makeValidator.ts +++ b/packages/catalog-model/src/validation/makeValidator.ts @@ -28,6 +28,7 @@ const defaultValidators: Validators = { isValidLabelValue: KubernetesValidatorFunctions.isValidLabelValue, isValidAnnotationKey: KubernetesValidatorFunctions.isValidAnnotationKey, isValidAnnotationValue: KubernetesValidatorFunctions.isValidAnnotationValue, + isValidTag: CommonValidatorFunctions.isValidDnsLabel, }; export function makeValidator(overrides: Partial = {}): Validators { diff --git a/packages/catalog-model/src/validation/types.ts b/packages/catalog-model/src/validation/types.ts index 81209bfb75..ff00036991 100644 --- a/packages/catalog-model/src/validation/types.ts +++ b/packages/catalog-model/src/validation/types.ts @@ -24,4 +24,5 @@ export type Validators = { isValidLabelValue(value: any): boolean; isValidAnnotationKey(value: any): boolean; isValidAnnotationValue(value: any): boolean; + isValidTag(value: any): boolean; }; diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json index 092a2a5167..4132907e70 100644 --- a/packages/cli-common/package.json +++ b/packages/cli-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli-common", "description": "Common functionality used by cli, backend, and create-app", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "main": "src/index.ts", "types": "src/index.ts", @@ -29,7 +29,6 @@ "clean": "backstage-cli clean" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/cli-common/src/paths.ts b/packages/cli-common/src/paths.ts index 70761ad2d7..0db712e6cb 100644 --- a/packages/cli-common/src/paths.ts +++ b/packages/cli-common/src/paths.ts @@ -106,7 +106,10 @@ export function findOwnRootDir(ownDir: string) { */ export function findPaths(searchDir: string): Paths { const ownDir = findOwnDir(searchDir); - const targetDir = fs.realpathSync(process.cwd()); + // Drive letter can end up being lowercased here on Windows, bring back to uppercase for consistency + const targetDir = fs + .realpathSync(process.cwd()) + .replace(/^[a-z]:/, str => str.toUpperCase()); // Lazy load this as it will throw an error if we're not inside the Backstage repo. let ownRoot = ''; diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js index 8389f5c508..113ba55818 100644 --- a/packages/cli/config/eslint.backend.js +++ b/packages/cli/config/eslint.backend.js @@ -28,11 +28,14 @@ module.exports = { env: { jest: true, }, + globals: { + __non_webpack_require__: 'readonly', + }, parserOptions: { ecmaVersion: 2018, sourceType: 'module', }, - ignorePatterns: ['.eslintrc.js', '**/dist/**'], + ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'], rules: { 'no-console': 0, // Permitted in console programs 'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()' diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index 2416a250e1..3e171d1f82 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -39,7 +39,7 @@ module.exports = { version: 'detect', }, }, - ignorePatterns: ['.eslintrc.js', '**/dist/**'], + ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'], rules: { 'import/newline-after-import': 'error', 'import/no-duplicates': 'warn', @@ -56,7 +56,12 @@ module.exports = { '@typescript-eslint/no-unused-expressions': 'error', '@typescript-eslint/no-unused-vars': [ 'warn', - { vars: 'all', args: 'after-used', ignoreRestSiblings: true }, + { + vars: 'all', + args: 'after-used', + ignoreRestSiblings: true, + argsIgnorePattern: '^_', + }, ], 'no-restricted-imports': [ 2, diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 77d3a84e82..f69e18190d 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -25,40 +25,6 @@ async function getConfig() { return require(path.resolve('jest.config.ts')); } - const options = { - rootDir: path.resolve('src'), - coverageDirectory: path.resolve('coverage'), - collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'], - moduleNameMapper: { - '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), - }, - - // We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed - // TODO: jest is working on module support, it's possible that we can remove this in the future - transform: { - '\\.esm\\.js$': require.resolve('jest-esm-transformer'), - '\\.(js|jsx|ts|tsx)': require.resolve('ts-jest'), - '\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)': require.resolve( - './jestFileTransform.js', - ), - }, - - // A bit more opinionated - testMatch: ['**/?(*.)test.{js,jsx,mjs,ts,tsx}'], - - // Default behaviour is to not apply transforms for node_modules, but we still want - // to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages. - // The @kyma-project/asyncapi-react library needs to be transformed. - transformIgnorePatterns: [ - '/node_modules/(?!@kyma-project/asyncapi-react/)(?!.*\\.(?:esm\\.js|bmp|gif|jpg|jpeg|png|frag|xml|svg)$)', - ], - }; - - // Use src/setupTests.ts as the default location for configuring test env - if (fs.existsSync('src/setupTests.ts')) { - options.setupFilesAfterEnv = ['/setupTests.ts']; - } - // We read all "jest" config fields in package.json files all the way to the filesystem root. // All configs are merged together to create the final config, with longer paths taking precedence. // The merging of the configs is shallow, meaning e.g. all transforms are replaced if new ones are defined. @@ -89,6 +55,56 @@ async function getConfig() { currentPath = newPath; } + // We add an additional Jest config parameter only known by the Backstage CLI + // called `transformModules`. It's a list of modules that we want to apply + // our configured jest transformations for. + // This is useful when packages are published in untranspiled ESM or TS form. + const transformModules = pkgJsonConfigs + .flatMap(conf => { + const modules = conf.transformModules || []; + delete conf.transformModules; + return modules; + }) + .map(name => `${name}/`) + .join('|'); + const transformModulePattern = transformModules && `(?!${transformModules})`; + + const options = { + rootDir: path.resolve('src'), + coverageDirectory: path.resolve('coverage'), + collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'], + moduleNameMapper: { + '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), + }, + + // We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed + // TODO: jest is working on module support, it's possible that we can remove this in the future + transform: { + '\\.esm\\.js$': require.resolve('jest-esm-transformer'), + '\\.(js|jsx|ts|tsx)$': [ + require.resolve('ts-jest'), + { isolatedModules: true }, + ], + '\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)$': require.resolve( + './jestFileTransform.js', + ), + }, + + // A bit more opinionated + testMatch: ['**/?(*.)test.{js,jsx,mjs,ts,tsx}'], + + // Default behaviour is to not apply transforms for node_modules, but we still want + // to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages. + transformIgnorePatterns: [ + `/node_modules/${transformModulePattern}(?:(?!\\.esm).)*\\.(?:js|json)$`, + ], + }; + + // Use src/setupTests.ts as the default location for configuring test env + if (fs.existsSync('src/setupTests.ts')) { + options.setupFilesAfterEnv = ['/setupTests.ts']; + } + return Object.assign(options, ...pkgJsonConfigs); } diff --git a/packages/cli/config/tsconfig.json b/packages/cli/config/tsconfig.json index ce031bc5e7..ef1a8df353 100644 --- a/packages/cli/config/tsconfig.json +++ b/packages/cli/config/tsconfig.json @@ -25,6 +25,7 @@ "removeComments": false, "resolveJsonModule": true, "sourceMap": false, + "skipLibCheck": true, "strict": true, "strictBindCallApply": true, "strictFunctionTypes": true, diff --git a/packages/cli/package.json b/packages/cli/package.json index cc5b3edc9b..492cd3b958 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public" @@ -21,7 +21,6 @@ "build": "backstage-cli build --outputs cjs", "lint": "backstage-cli lint", "test": "backstage-cli test", - "test:e2e": "node e2e-test/cli-e2e-test.js", "clean": "backstage-cli clean", "start": "nodemon --" }, @@ -29,9 +28,9 @@ "backstage-cli": "bin/backstage-cli" }, "dependencies": { - "@backstage/cli-common": "^0.1.1-alpha.18", - "@backstage/config": "^0.1.1-alpha.18", - "@backstage/config-loader": "^0.1.1-alpha.18", + "@backstage/cli-common": "^0.1.1-alpha.21", + "@backstage/config": "^0.1.1-alpha.21", + "@backstage/config-loader": "^0.1.1-alpha.21", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", @@ -41,17 +40,17 @@ "@rollup/plugin-yaml": "^2.1.1", "@spotify/eslint-config": "^7.0.1", "@sucrase/webpack-loader": "^2.0.0", - "@svgr/plugin-jsx": "4.3.x", - "@svgr/plugin-svgo": "4.3.x", + "@svgr/plugin-jsx": "5.4.x", + "@svgr/plugin-svgo": "5.4.x", "@svgr/rollup": "5.4.x", - "@svgr/webpack": "4.3.x", + "@svgr/webpack": "5.4.x", "@types/start-server-webpack-plugin": "^2.2.0", "@types/webpack-env": "^1.15.2", - "@types/webpack-node-externals": "^1.7.1", + "@types/webpack-node-externals": "^2.5.0", "bfj": "^7.0.2", "chalk": "^4.0.0", "chokidar": "^3.3.1", - "commander": "^4.1.1", + "commander": "^6.1.0", "css-loader": "^3.5.3", "dashify": "^2.0.0", "diff": "^4.0.2", @@ -69,9 +68,7 @@ "jest-css-modules": "^2.1.0", "jest-esm-transformer": "^1.0.0", "mini-css-extract-plugin": "^0.9.0", - "node-fetch": "^2.6.0", "ora": "^4.0.3", - "pgtools": "^0.3.0", "raw-loader": "^4.0.1", "react": "^16.0.0", "react-dev-utils": "^10.2.1", @@ -79,7 +76,7 @@ "recursive-readdir": "^2.2.2", "replace-in-file": "^6.0.0", "rollup": "2.23.x", - "rollup-plugin-dts": "^1.4.6", + "rollup-plugin-dts": "1.4.11", "rollup-plugin-esbuild": "^2.0.0", "rollup-plugin-image-files": "^1.4.2", "rollup-plugin-peer-deps-external": "^2.2.2", @@ -95,7 +92,7 @@ "url-loader": "^4.1.0", "webpack": "^4.41.6", "webpack-dev-server": "^3.10.3", - "webpack-node-externals": "^1.7.2", + "webpack-node-externals": "^2.5.0", "yaml": "^1.10.0", "yml-loader": "^2.1.0", "yn": "^4.0.0" @@ -105,7 +102,7 @@ "@types/fs-extra": "^9.0.1", "@types/html-webpack-plugin": "^3.2.2", "@types/http-proxy": "^1.17.4", - "@types/inquirer": "^6.5.0", + "@types/inquirer": "^7.3.1", "@types/mini-css-extract-plugin": "^0.9.1", "@types/node": "^13.7.2", "@types/ora": "^3.2.0", @@ -118,9 +115,7 @@ "@types/webpack-dev-server": "^3.10.0", "del": "^5.1.0", "nodemon": "^2.0.2", - "tree-kill": "^1.2.2", - "ts-node": "^8.6.2", - "zombie": "^6.1.4" + "ts-node": "^8.6.2" }, "files": [ "asset-types", diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts index 21d6924b15..5d499cbc2d 100644 --- a/packages/cli/src/commands/app/build.ts +++ b/packages/cli/src/commands/app/build.ts @@ -22,7 +22,7 @@ import { buildBundle } from '../../lib/bundler'; export default async (cmd: Command) => { const appConfigs = await loadConfig({ - env: 'production', + env: process.env.NODE_ENV ?? 'production', rootPaths: [paths.targetRoot, paths.targetDir], }); await buildBundle({ diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts index a04e73dceb..c748d12a75 100644 --- a/packages/cli/src/commands/app/serve.ts +++ b/packages/cli/src/commands/app/serve.ts @@ -22,7 +22,7 @@ import { serveBundle } from '../../lib/bundler'; export default async (cmd: Command) => { const appConfigs = await loadConfig({ - env: 'development', + env: process.env.NODE_ENV ?? 'development', rootPaths: [paths.targetRoot, paths.targetDir], }); const waitForExit = await serveBundle({ diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts index 258ef102d7..b6e9da7b39 100644 --- a/packages/cli/src/commands/backend/buildImage.ts +++ b/packages/cli/src/commands/backend/buildImage.ts @@ -15,28 +15,65 @@ */ import fs from 'fs-extra'; +import { join as joinPath, relative as relativePath } from 'path'; import { createDistWorkspace } from '../../lib/packager'; import { paths } from '../../lib/paths'; import { run } from '../../lib/run'; +import { Command } from 'commander'; const PKG_PATH = 'package.json'; -export default async (imageTag: string) => { +export default async (cmd: Command) => { + // Skip the preparation steps if we're being asked for help + if (cmd.args.includes('--help')) { + await run('docker', ['image', 'build', '--help']); + return; + } + const pkgPath = paths.resolveTarget(PKG_PATH); const pkg = await fs.readJson(pkgPath); + const appConfigs = await findAppConfigs(); const tempDistWorkspace = await createDistWorkspace([pkg.name], { + buildDependencies: Boolean(cmd.build), files: [ 'package.json', 'yarn.lock', - 'app-config.yaml', + ...appConfigs, { src: paths.resolveTarget('Dockerfile'), dest: 'Dockerfile' }, ], + skeleton: 'skeleton.tar', }); console.log(`Dist workspace ready at ${tempDistWorkspace}`); - await run('docker', ['build', '.', '-t', imageTag], { + // all args are forwarded to docker build + await run('docker', ['image', 'build', '.', ...cmd.args], { cwd: tempDistWorkspace, }); await fs.remove(tempDistWorkspace); }; + +/** + * Find all config files to copy into the image + */ +async function findAppConfigs(): Promise { + const files = []; + + for (const name of await fs.readdir(paths.targetRoot)) { + if (name.startsWith('app-config.') && name.endsWith('.yaml')) { + files.push(name); + } + } + + if (paths.targetRoot !== paths.targetDir) { + const dirPath = relativePath(paths.targetRoot, paths.targetDir); + + for (const name of await fs.readdir(paths.targetDir)) { + if (name.startsWith('app-config.') && name.endsWith('.yaml')) { + files.push(joinPath(dirPath, name)); + } + } + } + + return files; +} diff --git a/packages/cli/src/commands/backend/dev.ts b/packages/cli/src/commands/backend/dev.ts index 91c08af201..8b2ff9285a 100644 --- a/packages/cli/src/commands/backend/dev.ts +++ b/packages/cli/src/commands/backend/dev.ts @@ -22,12 +22,14 @@ import { serveBackend } from '../../lib/bundler/backend'; export default async (cmd: Command) => { const appConfigs = await loadConfig({ - env: 'development', + env: process.env.NODE_ENV ?? 'development', rootPaths: [paths.targetRoot, paths.targetDir], }); + const waitForExit = await serveBackend({ entry: 'src/index', checksEnabled: cmd.check, + inspectEnabled: cmd.inspect, config: ConfigReader.fromConfigs(appConfigs), appConfigs, }); diff --git a/packages/cli/src/commands/clean/clean.ts b/packages/cli/src/commands/clean/clean.ts index 2fa3e4a18f..bc2bcd23ac 100644 --- a/packages/cli/src/commands/clean/clean.ts +++ b/packages/cli/src/commands/clean/clean.ts @@ -19,5 +19,6 @@ import { paths } from '../../lib/paths'; export default async function clean() { await fs.remove(paths.resolveTarget('dist')); + await fs.remove(paths.resolveTarget('dist-types')); await fs.remove(paths.resolveTarget('coverage')); } diff --git a/packages/cli/src/commands/create-plugin/createPlugin.test.ts b/packages/cli/src/commands/create-plugin/createPlugin.test.ts index 57fa33d173..72b95b072c 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.test.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.test.ts @@ -59,7 +59,7 @@ describe('createPlugin', () => { await createTemporaryPluginFolder(tempDir); await movePlugin(tempDir, pluginDir, id); await expect(fs.pathExists(pluginDir)).resolves.toBe(true); - expect(pluginDir).toMatch(`/plugins\/${id}`); + expect(pluginDir).toMatch(path.join('', 'plugins', id)); } finally { await del(tempDir, { force: true }); await del(rootDir, { force: true }); diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index 47b883882b..5e6882a4a0 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -28,6 +28,7 @@ import { } from '../../lib/codeowners'; import { paths } from '../../lib/paths'; import { Task, templatingTask } from '../../lib/tasks'; +import { version as backstageVersion } from '../../lib/version'; const exec = promisify(execCb); @@ -239,7 +240,11 @@ export default async () => { await createTemporaryPluginFolder(tempDir); Task.section('Preparing files'); - await templatingTask(templateDir, tempDir, { ...answers, version }); + await templatingTask(templateDir, tempDir, { + ...answers, + version, + backstageVersion, + }); Task.section('Moving to final location'); await movePlugin(tempDir, pluginDir, answers.id); diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts new file mode 100644 index 0000000000..4e031cdbc1 --- /dev/null +++ b/packages/cli/src/commands/index.ts @@ -0,0 +1,172 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CommanderStatic } from 'commander'; +import { exitWithError } from '../lib/errors'; + +export function registerCommands(program: CommanderStatic) { + program + .command('app:build') + .description('Build an app for a production release') + .option('--stats', 'Write bundle stats to output directory') + .action(lazy(() => import('./app/build').then(m => m.default))); + + program + .command('app:serve') + .description('Serve an app for local development') + .option('--check', 'Enable type checking and linting') + .action(lazy(() => import('./app/serve').then(m => m.default))); + + program + .command('backend:build') + .description('Build a backend plugin') + .action(lazy(() => import('./backend/build').then(m => m.default))); + + program + .command('backend:build-image') + .allowUnknownOption(true) + .helpOption(', --backstage-cli-help') // Let docker handle --help + .option('--build', 'Build packages before packing them into the image') + .description( + 'Bundles the package into a docker image. All extra args are forwarded to docker image build', + ) + .action(lazy(() => import('./backend/buildImage').then(m => m.default))); + + program + .command('backend:dev') + .description('Start local development server with HMR for the backend') + .option('--check', 'Enable type checking and linting') + .option('--inspect', 'Enable debugger') + .action(lazy(() => import('./backend/dev').then(m => m.default))); + + program + .command('app:diff') + .option('--check', 'Fail if changes are required') + .option('--yes', 'Apply all changes') + .description('Diff an existing app with the creation template') + .action(lazy(() => import('./app/diff').then(m => m.default))); + + program + .command('create-plugin') + .description('Creates a new plugin in the current repository') + .action( + lazy(() => import('./create-plugin/createPlugin').then(m => m.default)), + ); + + program + .command('remove-plugin') + .description('Removes plugin in the current repository') + .action( + lazy(() => import('./remove-plugin/removePlugin').then(m => m.default)), + ); + + program + .command('plugin:build') + .description('Build a plugin') + .action(lazy(() => import('./plugin/build').then(m => m.default))); + + program + .command('plugin:serve') + .description('Serves the dev/ folder of a plugin') + .option('--check', 'Enable type checking and linting') + .action(lazy(() => import('./plugin/serve').then(m => m.default))); + + program + .command('plugin:export') + .description('Exports the dev/ folder of a plugin') + .option('--stats', 'Write bundle stats to output directory') + .action(lazy(() => import('./plugin/export').then(m => m.default))); + + program + .command('plugin:diff') + .option('--check', 'Fail if changes are required') + .option('--yes', 'Apply all changes') + .description('Diff an existing plugin with the creation template') + .action(lazy(() => import('./plugin/diff').then(m => m.default))); + + program + .command('build') + .description('Build a package for publishing') + .option('--outputs ', 'List of formats to output [types,cjs,esm]') + .action(lazy(() => import('./build').then(m => m.default))); + + program + .command('lint') + .option( + '--format ', + 'Lint report output format', + 'eslint-formatter-friendly', + ) + .option('--fix', 'Attempt to automatically fix violations') + .description('Lint a package') + .action(lazy(() => import('./lint').then(m => m.default))); + + program + .command('test') + .allowUnknownOption(true) // Allows the command to run, but we still need to parse raw args + .helpOption(', --backstage-cli-help') // Let Jest handle help + .description('Run tests, forwarding args to Jest, defaulting to watch mode') + .action(lazy(() => import('./testCommand').then(m => m.default))); + + program + .command('config:print') + .option('--with-secrets', 'Include secrets in the printed configuration') + .option( + '--env ', + 'The environment to print configuration for [NODE_ENV or development]', + ) + .option( + '--format ', + 'Format to print the configuration in, either json or yaml [yaml]', + ) + .description('Print the app configuration for the current package') + .action(lazy(() => import('./config/print').then(m => m.default))); + + program + .command('prepack') + .description('Prepares a package for packaging before publishing') + .action(lazy(() => import('./pack').then(m => m.pre))); + + program + .command('postpack') + .description('Restores the changes made by the prepack command') + .action(lazy(() => import('./pack').then(m => m.post))); + + program + .command('clean') + .description('Delete cache directories') + .action(lazy(() => import('./clean/clean').then(m => m.default))); + + program + .command('build-workspace ...') + .description('Builds a temporary dist workspace from the provided packages') + .action(lazy(() => import('./buildWorkspace').then(m => m.default))); +} + +// Wraps an action function so that it always exits and handles errors +function lazy( + getActionFunc: () => Promise<(...args: any[]) => Promise>, +): (...args: any[]) => Promise { + return async (...args: any[]) => { + try { + const actionFunc = await getActionFunc(); + await actionFunc(...args); + process.exit(0); + } catch (error) { + exitWithError(error); + } + }; +} diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index b355939e55..7b4532ffa7 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -22,7 +22,7 @@ export default async (cmd: Command, cmdArgs: string[]) => { const args = [ '--ext=js,jsx,ts,tsx', '--max-warnings=0', - '--format=eslint-formatter-friendly', + `--format=${cmd.format}`, ...(cmdArgs ?? [paths.targetDir]), ]; if (cmd.fix) { diff --git a/packages/cli/src/commands/plugin/diff.ts b/packages/cli/src/commands/plugin/diff.ts index 66db692ab4..99af093f2c 100644 --- a/packages/cli/src/commands/plugin/diff.ts +++ b/packages/cli/src/commands/plugin/diff.ts @@ -25,7 +25,7 @@ import { yesPromptFunc, } from '../../lib/diff'; import { paths } from '../../lib/paths'; -import { version } from '../../lib/version'; +import { version as backstageVersion } from '../../lib/version'; export type PluginData = { id: string; @@ -62,9 +62,12 @@ export default async (cmd: Command) => { promptFunc = yesPromptFunc; } + const { version } = await fs.readJson(paths.resolveTargetRoot('lerna.json')); + const data = await readPluginData(); const templateFiles = await diffTemplateFiles('default-plugin', { version, + backstageVersion, ...data, }); await handleAllFiles(fileHandlers, templateFiles, promptFunc); diff --git a/packages/cli/src/commands/plugin/export.ts b/packages/cli/src/commands/plugin/export.ts index 8f3c132285..8cbbe5b3ee 100644 --- a/packages/cli/src/commands/plugin/export.ts +++ b/packages/cli/src/commands/plugin/export.ts @@ -22,7 +22,7 @@ import { buildBundle } from '../../lib/bundler'; export default async (cmd: Command) => { const appConfigs = await loadConfig({ - env: 'production', + env: process.env.NODE_ENV ?? 'production', rootPaths: [paths.targetRoot, paths.targetDir], }); await buildBundle({ diff --git a/packages/cli/src/commands/plugin/serve.ts b/packages/cli/src/commands/plugin/serve.ts index a677bec918..8a04df8837 100644 --- a/packages/cli/src/commands/plugin/serve.ts +++ b/packages/cli/src/commands/plugin/serve.ts @@ -22,7 +22,7 @@ import { serveBundle } from '../../lib/bundler'; export default async (cmd: Command) => { const appConfigs = await loadConfig({ - env: 'development', + env: process.env.NODE_ENV ?? 'development', rootPaths: [paths.targetRoot, paths.targetDir], }); const waitForExit = await serveBundle({ diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 465260008d..3ffa8d63cf 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -39,7 +39,7 @@ const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; const tempDir = path.join(os.tmpdir(), 'remove-plugin-test'); const removeEmptyLines = (file: string): string => - file.split('\n').filter(Boolean).join('\n'); + file.split(/\r?\n/).filter(Boolean).join('\n'); const createTestPackageFile = async ( testFilePath: string, @@ -66,7 +66,7 @@ const createTestPluginFile = async ( fse.copyFileSync(pluginsFilePath, testFilePath); const pluginNameCapitalized = testPluginName .split('-') - .map((name) => capitalize(name)) + .map(name => capitalize(name)) .join(''); const exportStatement = `export { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; addExportStatement(testFilePath, exportStatement); @@ -100,7 +100,7 @@ describe('removePlugin', () => { const packageFileContent = removeEmptyLines( fse.readFileSync(packageFilePath, 'utf8'), ); - expect(testFileContent === packageFileContent).toBe(true); + expect(testFileContent).toBe(packageFileContent); } finally { fse.removeSync(testFilePath); } @@ -117,7 +117,7 @@ describe('removePlugin', () => { const pluginsFileContent = removeEmptyLines( fse.readFileSync(pluginsFilePaths, 'utf8'), ); - expect(testFileContent === pluginsFileContent).toBe(true); + expect(testFileContent).toBe(pluginsFileContent); } finally { fse.removeSync(testFilePath); } @@ -138,7 +138,7 @@ describe('removePlugin', () => { 'test@gmail.com', ]); await removePluginFromCodeOwners(testFilePath, testPluginName); - expect(testFileContent === codeOwnersFileContent).toBeTruthy(); + expect(testFileContent).toBe(codeOwnersFileContent); } finally { if (fse.existsSync(testFilePath)) fse.removeSync(testFilePath); } diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts index b9bf1c4af3..1395384022 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -84,7 +84,7 @@ const removeAllStatementsContainingID = async (file: string, ID: string) => { const originalContent = await fse.readFile(file, 'utf8'); const contentAfterRemoval = originalContent .split('\n') - .filter((statement) => !statement.includes(`${ID}`)) // get rid of lines with pluginName + .filter(statement => !statement.includes(`${ID}`)) // get rid of lines with pluginName .join('\n'); if (originalContent !== contentAfterRemoval) { await fse.writeFile(file, contentAfterRemoval, 'utf8'); @@ -100,7 +100,7 @@ export const removeReferencesFromPluginsFile = async ( ) => { const pluginNameCapitalized = pluginName .split('-') - .map((name) => capitalize(name)) + .map(name => capitalize(name)) .join(''); await Task.forItem('removing', 'export references', async () => { diff --git a/packages/cli/src/commands/testCommand.ts b/packages/cli/src/commands/testCommand.ts index 9969aee3b4..2c3f9fe895 100644 --- a/packages/cli/src/commands/testCommand.ts +++ b/packages/cli/src/commands/testCommand.ts @@ -57,6 +57,12 @@ export default async (cmd: Command) => { } } + // This is the only thing that is not implemented by jest.run(), so we do it here instead + // https://github.com/facebook/jest/blob/cd8828f7bbec6e55b4df5e41e853a5133c4a3ee1/packages/jest-cli/bin/jest.js#L12 + if (!process.env.NODE_ENV) { + (process.env as any).NODE_ENV = 'test'; + } + // eslint-disable-next-line jest/no-jest-import await require('jest').run(args); }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4b9dc4bab7..e278164f4c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -18,145 +18,12 @@ import program from 'commander'; import chalk from 'chalk'; import { exitWithError } from './lib/errors'; import { version } from './lib/version'; +import { registerCommands } from './commands'; const main = (argv: string[]) => { program.name('backstage-cli').version(version); - program - .command('app:build') - .description('Build an app for a production release') - .option('--stats', 'Write bundle stats to output directory') - .action(lazyAction(() => import('./commands/app/build'), 'default')); - - program - .command('app:serve') - .description('Serve an app for local development') - .option('--check', 'Enable type checking and linting') - .action(lazyAction(() => import('./commands/app/serve'), 'default')); - - program - .command('backend:build') - .description('Build a backend plugin') - .action(lazyAction(() => import('./commands/backend/build'), 'default')); - - program - .command('backend:build-image ') - .description( - 'Builds a docker image from the package, with all local deps included', - ) - .action( - lazyAction(() => import('./commands/backend/buildImage'), 'default'), - ); - - program - .command('backend:dev') - .description('Start local development server with HMR for the backend') - .option('--check', 'Enable type checking and linting') - .action(lazyAction(() => import('./commands/backend/dev'), 'default')); - - program - .command('app:diff') - .option('--check', 'Fail if changes are required') - .option('--yes', 'Apply all changes') - .description('Diff an existing app with the creation template') - .action(lazyAction(() => import('./commands/app/diff'), 'default')); - - program - .command('create-plugin') - .description('Creates a new plugin in the current repository') - .action( - lazyAction( - () => import('./commands/create-plugin/createPlugin'), - 'default', - ), - ); - - program - .command('remove-plugin') - .description('Removes plugin in the current repository') - .action( - lazyAction( - () => import('./commands/remove-plugin/removePlugin'), - 'default', - ), - ); - - program - .command('plugin:build') - .description('Build a plugin') - .action(lazyAction(() => import('./commands/plugin/build'), 'default')); - - program - .command('plugin:serve') - .description('Serves the dev/ folder of a plugin') - .option('--check', 'Enable type checking and linting') - .action(lazyAction(() => import('./commands/plugin/serve'), 'default')); - - program - .command('plugin:export') - .description('Exports the dev/ folder of a plugin') - .option('--stats', 'Write bundle stats to output directory') - .action(lazyAction(() => import('./commands/plugin/export'), 'default')); - - program - .command('plugin:diff') - .option('--check', 'Fail if changes are required') - .option('--yes', 'Apply all changes') - .description('Diff an existing plugin with the creation template') - .action(lazyAction(() => import('./commands/plugin/diff'), 'default')); - - program - .command('build') - .description('Build a package for publishing') - .option('--outputs ', 'List of formats to output [types,cjs,esm]') - .action(lazyAction(() => import('./commands/build'), 'default')); - - program - .command('lint') - .option('--fix', 'Attempt to automatically fix violations') - .description('Lint a package') - .action(lazyAction(() => import('./commands/lint'), 'default')); - - program - .command('test') - .allowUnknownOption(true) // Allows the command to run, but we still need to parse raw args - .helpOption(', --backstage-cli-help') // Let Jest handle help - .description('Run tests, forwarding args to Jest, defaulting to watch mode') - .action(lazyAction(() => import('./commands/testCommand'), 'default')); - - program - .command('config:print') - .option('--with-secrets', 'Include secrets in the printed configuration') - .option( - '--env ', - 'The environment to print configuration for [NODE_ENV or development]', - ) - .option( - '--format ', - 'Format to print the configuration in, either json or yaml [yaml]', - ) - .description('Print the app configuration for the current package') - .action(lazyAction(() => import('./commands/config/print'), 'default')); - - program - .command('prepack') - .description('Prepares a package for packaging before publishing') - .action(lazyAction(() => import('./commands/pack'), 'pre')); - - program - .command('postpack') - .description('Restores the changes made by the prepack command') - .action(lazyAction(() => import('./commands/pack'), 'post')); - - program - .command('clean') - .description('Delete cache directories') - .action(lazyAction(() => import('./commands/clean/clean'), 'default')); - - program - .command('build-workspace ...') - .description('Builds a temporary dist workspace from the provided packages') - .action(lazyAction(() => import('./commands/buildWorkspace'), 'default')); + registerCommands(program); program.on('command:*', () => { console.log(); @@ -175,25 +42,6 @@ const main = (argv: string[]) => { program.parse(argv); }; -// Wraps an action function so that it always exits and handles errors -function lazyAction( - actionRequireFunc: () => Promise< - { [name in Export]: (...args: T) => Promise } - >, - exportName: Export, -): (...args: T) => Promise { - return async (...args: T) => { - try { - const module = await actionRequireFunc(); - const actionFunc = module[exportName]; - await actionFunc(...args); - process.exit(0); - } catch (error) { - exitWithError(error); - } - }; -} - process.on('unhandledRejection', rejection => { if (rejection instanceof Error) { exitWithError(rejection); diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index e62e1fc6cd..afcedaae59 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -37,7 +37,7 @@ export const makeConfigs = async ( options: BuildOptions, ): Promise => { const typesInput = paths.resolveTargetRoot( - 'dist', + 'dist-types', relativePath(paths.targetRoot, paths.targetDir), 'src/index.d.ts', ); @@ -88,15 +88,18 @@ export const makeConfigs = async ( }), resolve({ mainFields }), commonjs({ - include: ['node_modules/**', '../../node_modules/**'], - exclude: ['**/*.stories.*', '**/*.test.*'], + include: /node_modules/, + exclude: [/\/[^/]+\.(?:stories|test)\.[^/]+$/], }), postcss(), - imageFiles({ exclude: '**/*.icon.svg' }), + imageFiles({ + exclude: /\.icon\.svg$/, + include: [/\.svg$/, /\.png$/, /\.gif$/, /\.jpg$/, /\.jpeg$/], + }), json(), yaml(), svgr({ - include: '**/*.icon.svg', + include: /\.icon\.svg$/, template: svgrTemplate, }), esbuild({ diff --git a/packages/cli/src/lib/bundler/backend.ts b/packages/cli/src/lib/bundler/backend.ts index ce821a585b..e3cc8af8d1 100644 --- a/packages/cli/src/lib/bundler/backend.ts +++ b/packages/cli/src/lib/bundler/backend.ts @@ -19,9 +19,13 @@ import { createBackendConfig } from './config'; import { resolveBundlingPaths } from './paths'; import { ServeOptions } from './types'; -export async function serveBackend(options: ServeOptions) { +export async function serveBackend( + options: ServeOptions & { + inspectEnabled: boolean; + }, +) { const paths = resolveBundlingPaths(options); - const config = createBackendConfig(paths, { + const config = await createBackendConfig(paths, { ...options, isDev: true, }); diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts index 1193ed505e..8746c807e7 100644 --- a/packages/cli/src/lib/bundler/bundle.ts +++ b/packages/cli/src/lib/bundler/bundle.ts @@ -36,7 +36,7 @@ export async function buildBundle(options: BuildOptions) { const { statsJsonEnabled } = options; const paths = resolveBundlingPaths(options); - const config = createConfig(paths, { + const config = await createConfig(paths, { ...options, checksEnabled: false, isDev: false, diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 56150ad968..118cb1b40c 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin'; @@ -25,6 +27,9 @@ import { Config } from '@backstage/config'; import { BundlingPaths } from './paths'; import { transforms } from './transforms'; import { BundlingOptions, BackendBundlingOptions } from './types'; +import { version } from '../../lib/version'; +import { paths as cliPaths } from '../../lib/paths'; +import { runPlain } from '../run'; export function resolveBaseUrl(config: Config): URL { const baseUrl = config.getString('app.baseUrl'); @@ -35,10 +40,40 @@ export function resolveBaseUrl(config: Config): URL { } } -export function createConfig( +async function readBuildInfo() { + const timestamp = Date.now(); + + let commit = 'unknown'; + try { + commit = await runPlain('git', 'rev-parse', 'HEAD'); + } catch (error) { + console.warn(`WARNING: Failed to read git commit, ${error}`); + } + + let gitVersion = 'unknown'; + try { + gitVersion = await runPlain('git', 'describe', '--always'); + } catch (error) { + console.warn(`WARNING: Failed to describe git version, ${error}`); + } + + const { version: packageVersion } = await fs.readJson( + cliPaths.resolveTarget('package.json'), + ); + + return { + cliVersion: version, + gitVersion, + packageVersion, + timestamp, + commit, + }; +} + +export async function createConfig( paths: BundlingPaths, options: BundlingOptions, -): webpack.Configuration { +): Promise { const { checksEnabled, isDev } = options; const { plugins, loaders } = transforms(options); @@ -81,6 +116,13 @@ export function createConfig( }), ); + const buildInfo = await readBuildInfo(); + plugins.push( + new webpack.DefinePlugin({ + 'process.env.BUILD_INFO': JSON.stringify(buildInfo), + }), + ); + return { mode: isDev ? 'development' : 'production', profile: false, @@ -130,14 +172,23 @@ export function createConfig( }; } -export function createBackendConfig( +export async function createBackendConfig( paths: BundlingPaths, options: BackendBundlingOptions, -): webpack.Configuration { +): Promise { const { checksEnabled, isDev } = options; const { loaders } = transforms(options); + // Find all local monorepo packages and their node_modules, and mark them as external. + const LernaProject = require('@lerna/project'); + const project = new LernaProject(cliPaths.targetDir); + const packages = await project.getPackages(); + const localPackageNames = packages.map((p: any) => p.name); + const moduleDirs = packages.map((p: any) => + resolvePath(p.location, 'node_modules'), + ); + return { mode: isDev ? 'development' : 'production', profile: false, @@ -152,11 +203,8 @@ export function createBackendConfig( externals: [ nodeExternals({ modulesDir: paths.rootNodeModules, - whitelist: ['webpack/hot/poll?100', /\@backstage\/.*/], - }), - nodeExternals({ - modulesDir: paths.targetNodeModules, - whitelist: ['webpack/hot/poll?100', /\@backstage\/.*/], + additionalModuleDirs: moduleDirs, + allowlist: ['webpack/hot/poll?100', ...localPackageNames], }), ], target: 'node' as const, @@ -178,7 +226,7 @@ export function createBackendConfig( resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'], mainFields: ['browser', 'module', 'main'], - modules: [paths.targetNodeModules, paths.rootNodeModules], + modules: [paths.rootNodeModules, ...moduleDirs], plugins: [ new ModuleScopePlugin( [paths.targetSrc, paths.targetDev], @@ -198,9 +246,17 @@ export function createBackendConfig( chunkFilename: isDev ? '[name].chunk.js' : '[name].[chunkhash:8].chunk.js', + ...(isDev + ? { + devtoolModuleFilenameTemplate: 'file:///[absolute-resource-path]', + } + : {}), }, plugins: [ - new StartServerPlugin('main.js'), + new StartServerPlugin({ + name: 'main.js', + nodeArgs: options.inspectEnabled ? ['--inspect'] : undefined, + }), new webpack.HotModuleReplacementPlugin(), ...(checksEnabled ? [ diff --git a/packages/cli/src/lib/bundler/paths.ts b/packages/cli/src/lib/bundler/paths.ts index 70e82d8d48..390e41a345 100644 --- a/packages/cli/src/lib/bundler/paths.ts +++ b/packages/cli/src/lib/bundler/paths.ts @@ -63,7 +63,6 @@ export function resolveBundlingPaths(options: BundlingPathsOptions) { targetDev: paths.resolveTarget('dev'), targetEntry: resolveTargetModule(entry), targetTsConfig: paths.resolveTargetRoot('tsconfig.json'), - targetNodeModules: paths.resolveTarget('node_modules'), targetPackageJson: paths.resolveTarget('package.json'), rootNodeModules: paths.resolveTargetRoot('node_modules'), root: paths.targetRoot, diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index c7bacc0c7d..e1fb560e22 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -30,7 +30,7 @@ export async function serveBundle(options: ServeOptions) { const paths = resolveBundlingPaths(options); const pkgPath = paths.targetPackageJson; const pkg = await fs.readJson(pkgPath); - const config = createConfig(paths, { + const config = await createConfig(paths, { ...options, isDev: true, baseUrl: url, @@ -42,7 +42,11 @@ export async function serveBundle(options: ServeOptions) { contentBase: paths.targetPublic, contentBasePublicPath: config.output?.publicPath, publicPath: config.output?.publicPath, - historyApiFallback: true, + historyApiFallback: { + // Paths with dots should still use the history fallback. + // See https://github.com/facebookincubator/create-react-app/issues/387. + disableDotRule: true, + }, clientLogLevel: 'warning', stats: 'errors-warnings', https: url.protocol === 'https:', diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 5d0ba66a51..03d68d9bb8 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -25,7 +25,9 @@ export type BundlingOptions = { baseUrl: URL; }; -export type BackendBundlingOptions = Omit; +export type BackendBundlingOptions = Omit & { + inspectEnabled: boolean; +}; export type ServeOptions = BundlingPathsOptions & { checksEnabled: boolean; diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index d65d1af304..564317e46a 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -15,10 +15,14 @@ */ import fs from 'fs-extra'; -import { resolve as resolvePath, relative as relativePath } from 'path'; +import { + join as joinPath, + resolve as resolvePath, + relative as relativePath, +} from 'path'; import { paths } from '../paths'; import { run } from '../run'; -import tar from 'tar'; +import tar, { CreateOptions } from 'tar'; import { tmpdir } from 'os'; type LernaPackage = { @@ -26,6 +30,7 @@ type LernaPackage = { private: boolean; location: string; scripts: Record; + get(key: string): any; }; type FileEntry = @@ -47,6 +52,17 @@ type Options = { * Defaults to ['yarn.lock', 'package.json']. */ files?: FileEntry[]; + + /** + * If set to true, the target packages are built before they are packaged into the workspace. + */ + buildDependencies?: boolean; + + /** + * If set, creates a skeleton tarball that contains all package.json files + * with the same structure as the workspace dir. + */ + skeleton?: 'skeleton.tar'; }; /** @@ -67,6 +83,13 @@ export async function createDistWorkspace( const targets = await findTargetPackages(packageNames); + if (options.buildDependencies) { + const scopeArgs = targets.flatMap(target => ['--scope', target.name]); + await run('yarn', ['lerna', 'run', ...scopeArgs, 'build'], { + cwd: paths.targetRoot, + }); + } + await moveToDistWorkspace(targetDir, targets); const files: FileEntry[] = options.files ?? ['yarn.lock', 'package.json']; @@ -76,6 +99,24 @@ export async function createDistWorkspace( const dest = typeof file === 'string' ? file : file.dest; await fs.copy(paths.resolveTargetRoot(src), resolvePath(targetDir, dest)); } + + if (options.skeleton) { + const skeletonFiles = targets.map(target => { + const dir = relativePath(paths.targetRoot, target.location); + return joinPath(dir, 'package.json'); + }); + + await tar.create( + { + file: resolvePath(targetDir, options.skeleton), + cwd: targetDir, + portable: true, + noMtime: true, + } as CreateOptions & { noMtime: boolean }, + skeletonFiles, + ); + } + return targetDir; } @@ -107,6 +148,26 @@ async function moveToDistWorkspace( strip: 1, }); await fs.remove(archivePath); + + // We remove the dependencies from package.json of packages that are marked + // as bundled, so that yarn doesn't try to install them. + if (target.get('bundled')) { + const pkgJson = await fs.readJson( + resolvePath(absoluteOutputPath, 'package.json'), + ); + delete pkgJson.dependencies; + delete pkgJson.devDependencies; + delete pkgJson.peerDependencies; + delete pkgJson.optionalDependencies; + + await fs.writeJson( + resolvePath(absoluteOutputPath, 'package.json'), + pkgJson, + { + spaces: 2, + }, + ); + } }), ); } diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index 4e85bf39bd..dbfb639b16 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -21,9 +21,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^{{version}}", - "@backstage/theme": "^{{version}}", - "@material-ui/core": "^4.9.1", + "@backstage/core": "^{{backstageVersion}}", + "@backstage/theme": "^{{backstageVersion}}", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", @@ -31,8 +31,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^{{version}}", - "@backstage/dev-utils": "^{{version}}", + "@backstage/cli": "^{{backstageVersion}}", + "@backstage/dev-utils": "^{{backstageVersion}}", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs index 6a410a6a28..1bf4d07cdb 100644 --- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs @@ -2,13 +2,13 @@ import { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; export const rootRouteRef = createRouteRef({ -path: '/{{ id }}', -title: '{{ id }}', + path: '/{{ id }}', + title: '{{ id }}', }); export const plugin = createPlugin({ -id: '{{ id }}', -register({ router }) { -router.addRoute(rootRouteRef, ExampleComponent); -}, + id: '{{ id }}', + register({ router }) { + router.addRoute(rootRouteRef, ExampleComponent); + }, }); diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index a2d7bbbc59..592b8eefb3 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public", @@ -30,15 +30,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.18", + "@backstage/config": "^0.1.1-alpha.21", "fs-extra": "^9.0.0", "yaml": "^1.9.2", "yup": "^0.29.1" }, "devDependencies": { "@types/jest": "^26.0.7", + "@types/mock-fs": "^4.10.0", "@types/node": "^12.0.0", - "@types/yup": "^0.28.2" + "@types/yup": "^0.28.2", + "mock-fs": "^4.13.0" }, "files": [ "dist" diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts index 6738611a17..02db134fe5 100644 --- a/packages/config-loader/src/index.ts +++ b/packages/config-loader/src/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ +export { readEnvConfig } from './lib'; export { loadConfig } from './loader'; export type { LoadConfigOptions } from './loader'; diff --git a/packages/config-loader/src/lib/env.test.ts b/packages/config-loader/src/lib/env.test.ts index 93bd962596..6b1e49365a 100644 --- a/packages/config-loader/src/lib/env.test.ts +++ b/packages/config-loader/src/lib/env.test.ts @@ -14,16 +14,16 @@ * limitations under the License. */ -import { readEnv } from './env'; +import { readEnvConfig } from './env'; -describe('readEnv', () => { +describe('readEnvConfig', () => { it('should return empty config for empty env', () => { - expect(readEnv({})).toEqual([]); + expect(readEnvConfig({})).toEqual([]); }); it('should return empty config for no matching keys', () => { expect( - readEnv({ + readEnvConfig({ NODE_ENV: 'production', NOPE_ENV: 'development', APP_CONFIG: 'foo', @@ -34,7 +34,7 @@ describe('readEnv', () => { it('should create config from env', () => { expect( - readEnv({ + readEnvConfig({ NODE_ENV: 'production', APP_CONFIG_foo: '"bar"', APP_CONFIG_numbers_a: '1', @@ -57,22 +57,22 @@ describe('readEnv', () => { }); it('should accept string values', () => { - expect(readEnv({ APP_CONFIG_foo: '"abc"', APP_CONFIG_bar: 'xyz' })).toEqual( - [ - { - data: { - foo: 'abc', - bar: 'xyz', - }, - context: 'env', + expect( + readEnvConfig({ APP_CONFIG_foo: '"abc"', APP_CONFIG_bar: 'xyz' }), + ).toEqual([ + { + data: { + foo: 'abc', + bar: 'xyz', }, - ], - ); + context: 'env', + }, + ]); }); it('should accept complex objects', () => { expect( - readEnv({ + readEnvConfig({ APP_CONFIG_foo: '{ "a": 123, "b": "123", "c": [] }', APP_CONFIG_bar: '[123, "abc", {}]', }), @@ -95,7 +95,7 @@ describe('readEnv', () => { ['APP_CONFIG_fo o'], ['APP_CONFIG_foo_(foo)_foo'], ])('should reject invalid key %p', key => { - expect(() => readEnv({ [key]: '0' })).toThrow( + expect(() => readEnvConfig({ [key]: '0' })).toThrow( `Invalid env config key '${key.replace('APP_CONFIG_', '')}'`, ); }); @@ -103,7 +103,7 @@ describe('readEnv', () => { it.each([['hello'], ['"hello'], ['{'], ['}']])( 'should fallback to string when invalid json value %p', value => { - expect(readEnv({ APP_CONFIG_foo: value })).toEqual([ + expect(readEnvConfig({ APP_CONFIG_foo: value })).toEqual([ { data: { foo: value, @@ -116,7 +116,7 @@ describe('readEnv', () => { it('should not allow null as a value', () => { expect(() => - readEnv({ + readEnvConfig({ APP_CONFIG_foo: 'null', }), ).toThrow( @@ -126,7 +126,7 @@ describe('readEnv', () => { it('should not allow duplicate values', () => { expect(() => - readEnv({ + readEnvConfig({ APP_CONFIG_foo_bar: '1', APP_CONFIG_foo_bar_baz: '2', }), @@ -137,7 +137,7 @@ describe('readEnv', () => { it('should not allow mixing of objects and other values', () => { expect(() => - readEnv({ + readEnvConfig({ APP_CONFIG_nested_foo: '1', APP_CONFIG_nested: '2', }), diff --git a/packages/config-loader/src/lib/env.ts b/packages/config-loader/src/lib/env.ts index 53c6d6c63b..84d39263e3 100644 --- a/packages/config-loader/src/lib/env.ts +++ b/packages/config-loader/src/lib/env.ts @@ -39,7 +39,7 @@ const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; * * APP_CONFIG_app_title='"My Title"' */ -export function readEnv(env: { +export function readEnvConfig(env: { [name: string]: string | undefined; }): AppConfig[] { let data: JsonObject | undefined = undefined; diff --git a/packages/config-loader/src/lib/index.ts b/packages/config-loader/src/lib/index.ts index 226932784b..40252b9cec 100644 --- a/packages/config-loader/src/lib/index.ts +++ b/packages/config-loader/src/lib/index.ts @@ -16,5 +16,5 @@ export { resolveStaticConfig } from './resolver'; export { readConfigFile } from './reader'; -export { readEnv } from './env'; +export { readEnvConfig } from './env'; export { readSecret } from './secrets'; diff --git a/packages/config-loader/src/lib/resolver.test.ts b/packages/config-loader/src/lib/resolver.test.ts index c804a6cef3..9fd71f2486 100644 --- a/packages/config-loader/src/lib/resolver.test.ts +++ b/packages/config-loader/src/lib/resolver.test.ts @@ -14,46 +14,48 @@ * limitations under the License. */ -const pathExists = jest.fn(); - -jest.mock('fs-extra', () => ({ pathExists })); - +import mockFs from 'mock-fs'; import { resolveStaticConfig } from './resolver'; +function normalizePaths(paths: string[]) { + return paths.map(p => + p + .replace(/^[a-z]:/i, '') + .split('\\') + .join('/'), + ); +} + describe('resolveStaticConfig', () => { afterEach(() => { - jest.resetAllMocks(); + mockFs.restore(); }); it('should resolve no files for empty roots', async () => { + mockFs({}); const resolved = await resolveStaticConfig({ env: 'development', rootPaths: [], }); - expect(resolved).toEqual([]); - expect(pathExists).not.toHaveBeenCalled(); + expect(normalizePaths(resolved)).toEqual([]); }); it('should resolve a single app-config', async () => { - pathExists.mockImplementation(async (path: string) => - ['/repo/app-config.yaml'].includes(path), - ); + mockFs({ '/repo/app-config.yaml': '' }); const resolved = await resolveStaticConfig({ env: 'development', rootPaths: ['/repo'], }); - expect(resolved).toEqual(['/repo/app-config.yaml']); - expect(pathExists).toHaveBeenCalledTimes(4); + expect(normalizePaths(resolved)).toEqual(['/repo/app-config.yaml']); }); it('should resolve a app-configs in different directories', async () => { - pathExists.mockImplementation(async (path: string) => - ['/repo/app-config.yaml', '/repo/packages/a/app-config.yaml'].includes( - path, - ), - ); + mockFs({ + '/repo/app-config.yaml': '', + '/repo/packages/a/app-config.yaml': '', + }); const resolved = await resolveStaticConfig({ env: 'development', rootPaths: [ @@ -64,53 +66,54 @@ describe('resolveStaticConfig', () => { ], }); - expect(resolved).toEqual([ + expect(normalizePaths(resolved)).toEqual([ '/repo/app-config.yaml', '/repo/packages/a/app-config.yaml', ]); - expect(pathExists).toHaveBeenCalledTimes(16); }); it('should resolve env and local configs', async () => { - pathExists.mockImplementation(async (path: string) => - [ - '/repo/app-config.yaml', - '/repo/app-config.local.yaml', - '/repo/app-config.production.yaml', - '/repo/app-config.production.local.yaml', - '/repo/app-config.development.local.yaml', - '/repo/packages/a/app-config.development.yaml', - '/repo/packages/a/app-config.local.yaml', - ].includes(path), - ); + mockFs({ + '/repo/app-config.yaml': '', + '/repo/app-config.local.yaml': '', + '/repo/app-config.production.yaml': '', + '/repo/app-config.production.local.yaml': '', + '/repo/app-config.development.local.yaml': '', + '/repo/packages/a/app-config.development.yaml': '', + '/repo/packages/a/app-config.local.yaml': '', + }); const resolved = await resolveStaticConfig({ env: 'development', rootPaths: ['/repo', '/repo/packages/a'], }); - expect(resolved).toEqual([ + expect(normalizePaths(resolved)).toEqual([ '/repo/app-config.yaml', '/repo/app-config.local.yaml', '/repo/app-config.development.local.yaml', '/repo/packages/a/app-config.local.yaml', '/repo/packages/a/app-config.development.yaml', ]); - expect(pathExists).toHaveBeenCalledTimes(8); }); it('resolves suffixed configs in the correct order', async () => { - pathExists.mockImplementation(async () => true); + mockFs({ + '/repo/app-config.yaml': '', + '/repo/app-config.local.yaml': '', + '/repo/app-config.production.yaml': '', + '/repo/app-config.production.local.yaml': '', + }); + const resolved = await resolveStaticConfig({ env: 'production', rootPaths: ['/repo'], }); - expect(resolved).toEqual([ + expect(normalizePaths(resolved)).toEqual([ '/repo/app-config.yaml', '/repo/app-config.local.yaml', '/repo/app-config.production.yaml', '/repo/app-config.production.local.yaml', ]); - expect(pathExists).toHaveBeenCalledTimes(4); }); }); diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts index 66894c7dec..a7547ee8fb 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/loader.test.ts @@ -15,37 +15,30 @@ */ import { loadConfig } from './loader'; - -jest.mock('fs-extra', () => { - const mockFiles: { [path in string]: string } = { - '/root/app-config.yaml': ` - app: - title: Example App - sessionKey: - $secret: - file: secrets/session-key.txt - `, - '/root/app-config.development.yaml': ` - app: - sessionKey: development-key - `, - '/root/secrets/session-key.txt': 'abc123', - }; - - return { - async readFile(path: string) { - if (path in mockFiles) { - return mockFiles[path]; - } - throw new Error(`File not found, ${path}`); - }, - async pathExists(path: string) { - return path in mockFiles; - }, - }; -}); +import mockFs from 'mock-fs'; describe('loadConfig', () => { + beforeAll(() => { + mockFs({ + '/root/app-config.yaml': ` + app: + title: Example App + sessionKey: + $secret: + file: secrets/session-key.txt + `, + '/root/app-config.development.yaml': ` + app: + sessionKey: development-key + `, + '/root/secrets/session-key.txt': 'abc123', + }); + }); + + afterAll(() => { + mockFs.restore(); + }); + it('loads config without secrets', async () => { await expect( loadConfig({ diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index 679ab71e45..cf5edd3ce3 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -20,7 +20,7 @@ import { AppConfig, JsonObject } from '@backstage/config'; import { resolveStaticConfig, readConfigFile, - readEnv, + readEnvConfig, readSecret, } from './lib'; @@ -102,7 +102,7 @@ export async function loadConfig( ); } - configs.push(...readEnv(process.env)); + configs.push(...readEnvConfig(process.env)); return configs; } diff --git a/packages/config/package.json b/packages/config/package.json index 9c8c81d31b..236964f2bd 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config", "description": "Config API used by Backstage core, backend, and CLI", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public", diff --git a/packages/config/src/reader.test.ts b/packages/config/src/reader.test.ts index 41cdc92938..3fe74aa637 100644 --- a/packages/config/src/reader.test.ts +++ b/packages/config/src/reader.test.ts @@ -88,7 +88,7 @@ function expectInvalidValues(config: ConfigReader) { "Invalid type in config for key 'string' in 'ctx', got string, wanted boolean", ); expect(() => config.getNumber('string')).toThrow( - "Invalid type in config for key 'string' in 'ctx', got string, wanted number", + "Unable to convert config value for key 'string' in 'ctx' to a number", ); expect(() => config.getString('one')).toThrow( "Invalid type in config for key 'one' in 'ctx', got number, wanted string", @@ -580,4 +580,17 @@ describe('ConfigReader.get()', () => { }, }); }); + + it('coerces number strings to numbers', () => { + const config = ConfigReader.fromConfigs([ + { + data: { + port: '123', + }, + context: '1', + }, + ]); + + expect(config.getNumber('port')).toEqual(123); + }); }); diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index 65143bbd95..eb8c91e366 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -49,6 +49,9 @@ const errors = { missing(key: string) { return `Missing required config value at '${key}'`; }, + convert(key: string, context: string, expected: string) { + return `Unable to convert config value for key '${key}' in '${context}' to a ${expected}`; + }, }; export class ConfigReader implements Config { @@ -183,10 +186,22 @@ export class ConfigReader implements Config { } getOptionalNumber(key: string): number | undefined { - return this.readConfigValue( + const value = this.readConfigValue( key, - value => typeof value === 'number' || { expected: 'number' }, + val => + typeof val === 'number' || + typeof val === 'string' || { expected: 'number' }, ); + if (typeof value === 'number' || value === undefined) { + return value; + } + const number = Number(value); + if (!Number.isFinite(number)) { + throw new Error( + errors.convert(this.fullKey(key), this.context, 'number'), + ); + } + return number; } getBoolean(key: string): boolean { diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 3e46874c1d..840cc36173 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -30,10 +30,12 @@ export type AppConfig = { }; export type Config = { + has(key: string): boolean; + keys(): string[]; - get(key: string): JsonValue; - getOptional(key: string): JsonValue | undefined; + get(key?: string): JsonValue; + getOptional(key?: string): JsonValue | undefined; getConfig(key: string): Config; getOptionalConfig(key: string): Config | undefined; diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 9ce1290a73..191394e536 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/config": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", "prop-types": "^15.7.2", @@ -41,8 +41,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/test-utils-core": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/test-utils-core": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/packages/core-api/src/apis/ApiFactoryRegistry.test.ts b/packages/core-api/src/apis/ApiFactoryRegistry.test.ts new file mode 100644 index 0000000000..9f44adaef3 --- /dev/null +++ b/packages/core-api/src/apis/ApiFactoryRegistry.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiFactoryRegistry } from './ApiFactoryRegistry'; +import { createApiRef } from './ApiRef'; + +const aRef = createApiRef({ id: 'a', description: '' }); +const aFactory1 = { api: aRef, deps: {}, factory: () => 1 }; +const aFactory2 = { api: aRef, deps: {}, factory: () => 2 }; +const bRef = createApiRef({ id: 'b', description: '' }); +const bFactory = { api: bRef, deps: {}, factory: () => 'x' }; +const cRef = createApiRef({ id: 'c', description: '' }); +const cFactory = { api: cRef, deps: {}, factory: () => 'y' }; + +describe('ApiFactoryRegistry', () => { + it('should be empty when created', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.getAllApis()).toEqual(new Set()); + }); + + it('should register a factory', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.getAllApis()).toEqual(new Set([aRef])); + }); + + it('should prioritize factories based on scope', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('default', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('app', aFactory2)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory2); + expect(registry.register('default', aFactory1)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory2); + expect(registry.register('static', aFactory1)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('static', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.register('app', aFactory2)).toBe(false); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.getAllApis()).toEqual(new Set([aRef])); + }); + + it('should register multiple factories without conflict', () => { + const registry = new ApiFactoryRegistry(); + expect(registry.register('static', aFactory1)).toBe(true); + expect(registry.register('default', bFactory)).toBe(true); + expect(registry.register('app', cFactory)).toBe(true); + expect(registry.get(aRef)).toBe(aFactory1); + expect(registry.get(bRef)).toBe(bFactory); + expect(registry.get(cRef)).toBe(cFactory); + expect(registry.getAllApis()).toEqual(new Set([aRef, bRef, cRef])); + }); +}); diff --git a/packages/core-api/src/apis/ApiFactoryRegistry.ts b/packages/core-api/src/apis/ApiFactoryRegistry.ts new file mode 100644 index 0000000000..0d36e6c550 --- /dev/null +++ b/packages/core-api/src/apis/ApiFactoryRegistry.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiFactoryHolder, + ApiFactory, + AnyApiRef, + AnyApiFactory, +} from './types'; +import { ApiRef } from './ApiRef'; + +type ApiFactoryScope = + | 'default' // Default factories registered by core and plugins + | 'app' // Factories registered in the app, overriding default ones + | 'static'; // APIs that can't be overridden, e.g. config + +enum ScopePriority { + default = 10, + app = 50, + static = 100, +} + +type FactoryTuple = { + priority: number; + factory: AnyApiFactory; +}; + +/** + * ApiFactoryRegistry is an ApiFactoryHolder implementation that enables + * registration of API Factories with different scope. + * + * Each scope has an assigned priority, where factories registered with + * higher priority scopes override ones with lower priority. + */ +export class ApiFactoryRegistry implements ApiFactoryHolder { + private readonly factories = new Map(); + + /** + * Register a new API factory. Returns true if the factory was added + * to the registry. + * + * A factory will not be added to the registry if there is already + * an existing factory with the same or higher priority. + */ + register( + scope: ApiFactoryScope, + factory: ApiFactory, + ) { + const priority = ScopePriority[scope]; + const existing = this.factories.get(factory.api); + if (existing && existing.priority >= priority) { + return false; + } + + this.factories.set(factory.api, { priority, factory }); + return true; + } + + get(api: ApiRef): ApiFactory | undefined { + const tuple = this.factories.get(api); + if (!tuple) { + return undefined; + } + return tuple.factory as ApiFactory; + } + + getAllApis(): Set { + return new Set(this.factories.keys()); + } +} diff --git a/packages/core-api/src/apis/ApiResolver.test.ts b/packages/core-api/src/apis/ApiResolver.test.ts new file mode 100644 index 0000000000..3cd40f289e --- /dev/null +++ b/packages/core-api/src/apis/ApiResolver.test.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiResolver } from './ApiResolver'; +import { createApiRef } from './ApiRef'; +import { ApiFactoryRegistry } from './ApiFactoryRegistry'; + +const aRef = createApiRef({ id: 'a', description: '' }); +const bRef = createApiRef({ id: 'b', description: '' }); +const cRef = createApiRef<{ x: string }>({ id: 'c', description: '' }); + +function createRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: {}, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: {}, + factory: () => 'b', + }); + registry.register('default', { + api: cRef, + deps: { b: bRef }, + factory: ({ b }) => ({ x: 'x', b }), + }); + return registry; +} + +function createLongCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: bRef }, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: { c: cRef }, + factory: () => 'b', + }); + registry.register('default', { + api: cRef, + deps: { a: aRef }, + factory: () => ({ x: 'x' }), + }); + return registry; +} + +function createShortCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { a: aRef }, + factory: () => 1, + }); + registry.register('default', { + api: bRef, + deps: { c: cRef }, + factory: () => 'b', + }); + registry.register('default', { + api: cRef, + deps: { b: bRef }, + factory: () => ({ x: 'x' }), + }); + return registry; +} + +describe('ApiResolver', () => { + it('should be created empty', () => { + const resolver = new ApiResolver(new ApiFactoryRegistry()); + expect(resolver.get(aRef)).toBe(undefined); + expect(resolver.get(bRef)).toBe(undefined); + expect(resolver.get(cRef)).toBe(undefined); + }); + + it('should instantiate APIs', () => { + const resolver = new ApiResolver(createRegistry()); + expect(resolver.get(aRef)).toBe(1); + expect(resolver.get(bRef)).toBe('b'); + expect(resolver.get(cRef)).toEqual({ x: 'x', b: 'b' }); + expect(resolver.get(cRef)).toBe(resolver.get(cRef)); + }); + + it('should detect long dependency cycles', () => { + const resolver = new ApiResolver(createLongCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + // Second call for same ref should still throw + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(cRef)).toThrow( + 'Circular dependency of api factory for apiRef{c}', + ); + }); + + it('should detect short dependency cycles', () => { + const resolver = new ApiResolver(createShortCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(cRef)).toThrow( + 'Circular dependency of api factory for apiRef{c}', + ); + }); + + it('should validate a factory holder', () => { + expect(() => { + ApiResolver.validateFactories(createRegistry(), [aRef, bRef, cRef]); + }).not.toThrow(); + }); + + it('should find dependency cycles with validation', () => { + const short = createShortCyclicRegistry(); + expect(() => + ApiResolver.validateFactories(short, short.getAllApis()), + ).toThrow('Circular dependency of api factory for apiRef{a}'); + expect(() => ApiResolver.validateFactories(short, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => ApiResolver.validateFactories(short, [cRef])).toThrow( + 'Circular dependency of api factory for apiRef{c}', + ); + + const long = createLongCyclicRegistry(); + expect(() => + ApiResolver.validateFactories(long, long.getAllApis()), + ).toThrow('Circular dependency of api factory for apiRef{a}'); + expect(() => ApiResolver.validateFactories(long, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => ApiResolver.validateFactories(long, [cRef])).toThrow( + 'Circular dependency of api factory for apiRef{c}', + ); + }); + + it('should only call factory func once', () => { + const registry = new ApiFactoryRegistry(); + const factory = jest.fn().mockReturnValue(2); + registry.register('default', { + api: aRef, + deps: {}, + factory, + }); + + const resolver = new ApiResolver(registry); + expect(factory).toHaveBeenCalledTimes(0); + expect(resolver.get(aRef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); + expect(resolver.get(aRef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core-api/src/apis/ApiTestRegistry.ts b/packages/core-api/src/apis/ApiResolver.ts similarity index 57% rename from packages/core-api/src/apis/ApiTestRegistry.ts rename to packages/core-api/src/apis/ApiResolver.ts index 7aed3c7917..cb65186a37 100644 --- a/packages/core-api/src/apis/ApiTestRegistry.ts +++ b/packages/core-api/src/apis/ApiResolver.ts @@ -15,38 +15,54 @@ */ import { ApiRef } from './ApiRef'; -import { TypesToApiRefs, AnyApiRef, ApiHolder, ApiFactory } from './types'; +import { + ApiHolder, + ApiFactoryHolder, + AnyApiRef, + TypesToApiRefs, +} from './types'; -export class ApiTestRegistry implements ApiHolder { +export class ApiResolver implements ApiHolder { private readonly apis = new Map(); - private factories = new Map< - AnyApiRef, - ApiFactory - >(); - private savedFactories = new Map< - AnyApiRef, - ApiFactory - >(); + + /** + * Validate factories by making sure that each of the apis can be created + * without hitting any circular dependencies. + */ + static validateFactories( + factories: ApiFactoryHolder, + apis: Iterable, + ) { + for (const api of apis) { + const heap = [api]; + const allDeps = new Set(); + + while (heap.length) { + const apiRef = heap.shift()!; + const factory = factories.get(apiRef); + if (!factory) { + continue; + } + + for (const dep of Object.values(factory.deps)) { + if (dep === api) { + throw new Error(`Circular dependency of api factory for ${api}`); + } + if (!allDeps.has(dep)) { + allDeps.add(dep); + heap.push(dep); + } + } + } + } + } + + constructor(private readonly factories: ApiFactoryHolder) {} get(ref: ApiRef): T | undefined { return this.load(ref); } - register(factory: ApiFactory): ApiTestRegistry { - this.factories.set(factory.implements, factory); - return this; - } - - reset() { - this.factories = this.savedFactories; - this.apis.clear(); - } - - save(): ApiTestRegistry { - this.savedFactories = new Map(this.factories); - return this; - } - private load(ref: ApiRef, loading: AnyApiRef[] = []): T | undefined { const impl = this.apis.get(ref); if (impl) { @@ -58,16 +74,11 @@ export class ApiTestRegistry implements ApiHolder { return undefined; } - if (loading.includes(factory.implements)) { - throw new Error( - `Circular dependency of api factory for ${factory.implements}`, - ); + if (loading.includes(factory.api)) { + throw new Error(`Circular dependency of api factory for ${factory.api}`); } - const deps = this.loadDeps(ref, factory.deps, [ - ...loading, - factory.implements, - ]); + const deps = this.loadDeps(ref, factory.deps, [...loading, factory.api]); const api = factory.factory(deps); this.apis.set(ref, api); return api as T; diff --git a/packages/core-api/src/apis/ApiTestRegistry.test.ts b/packages/core-api/src/apis/ApiTestRegistry.test.ts deleted file mode 100644 index fa42e64707..0000000000 --- a/packages/core-api/src/apis/ApiTestRegistry.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ApiTestRegistry } from './ApiTestRegistry'; -import { createApiRef } from './ApiRef'; - -describe('ApiTestRegistry', () => { - const aRef = createApiRef({ id: 'a', description: '' }); - const bRef = createApiRef({ id: 'b', description: '' }); - const cRef = createApiRef({ id: 'c', description: '' }); - - it('should be created', () => { - const registry = new ApiTestRegistry(); - expect(registry.get(aRef)).toBe(undefined); - expect(registry.get(bRef)).toBe(undefined); - expect(registry.get(cRef)).toBe(undefined); - }); - - it('should register a factory', () => { - const registry = new ApiTestRegistry(); - registry.register({ implements: aRef, deps: {}, factory: () => 3 }); - expect(registry.get(aRef)).toBe(3); - expect(registry.get(bRef)).toBe(undefined); - expect(registry.get(cRef)).toBe(undefined); - }); - - it('should remove factories when resetting', () => { - const registry = new ApiTestRegistry(); - registry.register({ implements: aRef, deps: {}, factory: () => 3 }); - expect(registry.get(aRef)).toBe(3); - registry.reset(); - expect(registry.get(aRef)).toBe(undefined); - }); - - it('should keep saved factories when resetting', () => { - const registry = new ApiTestRegistry(); - registry.register({ implements: aRef, deps: {}, factory: () => 3 }); - registry.save(); - registry.register({ implements: bRef, deps: {}, factory: () => 'x' }); - expect(registry.get(aRef)).toBe(3); - expect(registry.get(bRef)).toBe('x'); - registry.reset(); - expect(registry.get(aRef)).toBe(3); - expect(registry.get(bRef)).toBe(undefined); - }); - - it('should register factories with dependencies', () => { - // 100% coverage + happy typescript = hasOwnProperty + this atrocity - const cDeps = Object.create( - { c: cRef }, - { a: { enumerable: true, value: aRef } }, - ); - cDeps.b = bRef; - - const registry = new ApiTestRegistry(); - registry.register({ implements: aRef, deps: {}, factory: () => 3 }); - registry.register({ - implements: bRef, - deps: { dep: aRef }, - factory: ({ dep }) => `hello ${dep}`, - }); - registry.register({ - implements: cRef, - deps: cDeps, - factory: ({ a, b }) => b.repeat(a), - }); - expect(registry.get(aRef)).toBe(3); - expect(registry.get(bRef)).toBe('hello 3'); - expect(registry.get(cRef)).toBe('hello 3hello 3hello 3'); - }); - - it('should not allow cyclic dependencies', () => { - const registry = new ApiTestRegistry(); - registry.register({ - implements: aRef, - deps: { b: bRef }, - factory: () => 1, - }); - registry.register({ - implements: bRef, - deps: { c: cRef }, - factory: () => 'b', - }); - registry.register({ - implements: cRef, - deps: { a: aRef }, - factory: () => 'c', - }); - expect(() => registry.get(aRef)).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - expect(() => registry.get(bRef)).toThrow( - 'Circular dependency of api factory for apiRef{b}', - ); - expect(() => registry.get(cRef)).toThrow( - 'Circular dependency of api factory for apiRef{c}', - ); - }); - - it('should throw error if dependency is not available', () => { - const registry = new ApiTestRegistry(); - registry.register({ - implements: aRef, - deps: { b: bRef }, - factory: () => 1, - }); - expect(() => registry.get(aRef)).toThrow( - 'No API factory available for dependency apiRef{b} of dependent apiRef{a}', - ); - expect(registry.get(bRef)).toBe(undefined); - expect(registry.get(cRef)).toBe(undefined); - }); - - it('should only call factory func once', () => { - const registry = new ApiTestRegistry(); - const factory = jest.fn().mockReturnValue(2); - registry.register({ implements: aRef, deps: {}, factory }); - - expect(factory).toHaveBeenCalledTimes(0); - expect(registry.get(aRef)).toBe(2); - expect(factory).toHaveBeenCalledTimes(1); - expect(registry.get(aRef)).toBe(2); - expect(factory).toHaveBeenCalledTimes(1); - }); - - it('should call factory again after reset', () => { - const registry = new ApiTestRegistry(); - const factory = jest.fn().mockReturnValue(2); - registry.register({ implements: aRef, deps: {}, factory }); - registry.save(); - - expect(factory).toHaveBeenCalledTimes(0); - expect(registry.get(aRef)).toBe(2); - expect(factory).toHaveBeenCalledTimes(1); - expect(registry.get(aRef)).toBe(2); - expect(factory).toHaveBeenCalledTimes(1); - registry.reset(); - expect(registry.get(aRef)).toBe(2); - expect(factory).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/core-api/src/apis/definitions/DiscoveryApi.ts b/packages/core-api/src/apis/definitions/DiscoveryApi.ts new file mode 100644 index 0000000000..b0773086c7 --- /dev/null +++ b/packages/core-api/src/apis/definitions/DiscoveryApi.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createApiRef } from '../ApiRef'; + +/** + * The discovery API is used to provide a mechanism for plugins to + * discover the endpoint to use to talk to their backend counterpart. + * + * The purpose of the discovery API is to allow for many different deployment + * setups and routing methods through a central configuration, instead + * of letting each individual plugin manage that configuration. + * + * Implementations of the discovery API can be a simple as a URL pattern + * using the pluginId, but could also have overrides for individual plugins, + * or query a separate discovery service. + */ +export type DiscoveryApi = { + /** + * Returns the HTTP base backend URL for a given plugin, without a trailing slash. + * + * This method must always be called just before making a request. as opposed to + * fetching the URL when constructing an API client. That is to ensure that more + * flexible routing patterns can be supported. + * + * For example, asking for the URL for `auth` may return something + * like `https://backstage.example.com/api/auth` + */ + getBaseUrl(pluginId: string): Promise; +}; + +export const discoveryApiRef = createApiRef({ + id: 'core.discovery', + description: 'Provides service discovery of backend plugins', +}); diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index ec7aba81c4..1ff8c46dad 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -222,7 +222,7 @@ export const googleAuthApiRef = createApiRef< }); /** - * Provides authentication towards Github APIs. + * Provides authentication towards GitHub APIs. * * See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ * for a full list of supported scopes. @@ -231,7 +231,7 @@ export const githubAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi >({ id: 'core.auth.github', - description: 'Provides authentication towards Github APIs', + description: 'Provides authentication towards GitHub APIs', }); /** @@ -252,7 +252,7 @@ export const oktaAuthApiRef = createApiRef< }); /** - * Provides authentication towards Gitlab APIs. + * Provides authentication towards GitLab APIs. * * See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token * for a full list of supported scopes. @@ -261,14 +261,49 @@ export const gitlabAuthApiRef = createApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi >({ id: 'core.auth.gitlab', - description: 'Provides authentication towards Gitlab APIs', + description: 'Provides authentication towards GitLab APIs', +}); + +/** + * Provides authentication towards Auth0 APIs. + * + * See https://auth0.com/docs/scopes/current/oidc-scopes + * for a full list of supported scopes. + */ +export const auth0AuthApiRef = createApiRef< + OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi +>({ + id: 'core.auth.auth0', + description: 'Provides authentication towards Auth0 APIs', +}); + +/** + * Provides authentication towards Microsoft APIs and identities. + * + * For more info and a full list of supported scopes, see: + * - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent + * - https://docs.microsoft.com/en-us/graph/permissions-reference + */ +export const microsoftAuthApiRef = createApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionStateApi +>({ + id: 'core.auth.microsoft', + description: 'Provides authentication towards Microsoft APIs and identities', }); /** * Provides authentication for custom identity providers. */ export const oauth2ApiRef = createApiRef< - OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + SessionStateApi & + BackstageIdentityApi >({ id: 'core.auth.oauth2', description: 'Example of how to use oauth2 custom provider', diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts index c5d4a15117..678dce9e32 100644 --- a/packages/core-api/src/apis/definitions/index.ts +++ b/packages/core-api/src/apis/definitions/index.ts @@ -27,6 +27,7 @@ export * from './AppThemeApi'; export * from './ConfigApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; +export * from './DiscoveryApi'; export * from './IdentityApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; diff --git a/packages/core-api/src/apis/helpers.ts b/packages/core-api/src/apis/helpers.ts index d8f8e8ac11..7d616acd01 100644 --- a/packages/core-api/src/apis/helpers.ts +++ b/packages/core-api/src/apis/helpers.ts @@ -14,15 +14,36 @@ * limitations under the License. */ -import { ApiFactory } from './types'; +import { ApiFactory, TypesToApiRefs } from './types'; +import { ApiRef } from './ApiRef'; /** * Used to infer types for a standalone ApiFactory that isn't immediately passed * to another function. * This function doesn't actually do anything, it's only used to infer types. */ -export function createApiFactory( - factory: ApiFactory, -): ApiFactory { +export function createApiFactory< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } +>(factory: ApiFactory): ApiFactory; +export function createApiFactory( + api: ApiRef, + instance: Api, +): ApiFactory; +export function createApiFactory< + Api, + Deps extends { [name in string]: unknown } +>( + factory: ApiFactory | ApiRef, + instance?: Api, +): ApiFactory { + if ('id' in factory) { + return { + api: factory, + deps: {} as TypesToApiRefs, + factory: () => instance!, + }; + } return factory; } diff --git a/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts b/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts new file mode 100644 index 0000000000..9597443b98 --- /dev/null +++ b/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UrlPatternDiscovery } from './UrlPatternDiscovery'; + +describe('UrlPatternDiscovery', () => { + it('should not require interpolation', async () => { + const discoveryApi = UrlPatternDiscovery.compile('http://example.com'); + await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( + 'http://example.com', + ); + }); + + it('should use a plain pattern', async () => { + const discoveryApi = UrlPatternDiscovery.compile( + 'http://localhost:7000/{{ pluginId }}', + ); + await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( + 'http://localhost:7000/my-plugin', + ); + }); + + it('should allow for multiple interpolation points', async () => { + const discoveryApi = UrlPatternDiscovery.compile( + 'https://{{pluginId }}.example.com/api/{{ pluginId}}', + ); + await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( + 'https://my-plugin.example.com/api/my-plugin', + ); + }); + + it('should validate that the pattern is a valid URL', () => { + expect(() => { + UrlPatternDiscovery.compile('example.com'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: example.com'); + + expect(() => { + UrlPatternDiscovery.compile('http://'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: http://'); + + expect(() => { + UrlPatternDiscovery.compile('abc123'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: abc123'); + + expect(() => { + UrlPatternDiscovery.compile('http://example.com:{{pluginId}}'); + }).toThrow( + 'Invalid discovery URL pattern, Invalid URL: http://example.com:pluginId', + ); + + expect(() => { + UrlPatternDiscovery.compile('/{{pluginId}}'); + }).toThrow('Invalid discovery URL pattern, Invalid URL: /pluginId'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/{{pluginId}}?forbidden'); + }).toThrow('Invalid discovery URL pattern, URL must not have a query'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/{{pluginId}}#forbidden'); + }).toThrow('Invalid discovery URL pattern, URL must not have a hash'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/{{pluginId}}/'); + }).toThrow('Invalid discovery URL pattern, URL must not end with a slash'); + + expect(() => { + UrlPatternDiscovery.compile('http://localhost/'); + }).toThrow('Invalid discovery URL pattern, URL must not end with a slash'); + }); +}); diff --git a/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts b/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts new file mode 100644 index 0000000000..ca48784584 --- /dev/null +++ b/packages/core-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DiscoveryApi } from '../../definitions/DiscoveryApi'; + +/** + * UrlPatternDiscovery is a lightweight DiscoveryApi implementation. + * It uses a single template string to construct URLs for each plugin. + */ +export class UrlPatternDiscovery implements DiscoveryApi { + /** + * Creates a new UrlPatternDiscovery given a template. The the only + * interpolation done for the template is to replace instances of `{{pluginId}}` + * with the ID of the plugin being requested. + * + * Example pattern: `http://localhost:7000/api/{{ pluginId }}` + */ + static compile(pattern: string): UrlPatternDiscovery { + const parts = pattern.split(/\{\{\s*pluginId\s*\}\}/); + + try { + const urlStr = parts.join('pluginId'); + const url = new URL(urlStr); + if (url.hash) { + throw new Error('URL must not have a hash'); + } + if (url.search) { + throw new Error('URL must not have a query'); + } + if (urlStr.endsWith('/')) { + throw new Error('URL must not end with a slash'); + } + } catch (error) { + throw new Error(`Invalid discovery URL pattern, ${error.message}`); + } + + return new UrlPatternDiscovery(parts); + } + + private constructor(private readonly parts: string[]) {} + + async getBaseUrl(pluginId: string): Promise { + return this.parts.join(pluginId); + } +} diff --git a/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts b/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts new file mode 100644 index 0000000000..60a5b815e7 --- /dev/null +++ b/packages/core-api/src/apis/implementations/DiscoveryApi/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This folder contains implementations for all core APIs. +// +// Plugins should rely on these APIs for functionality as much as possible. + +export { UrlPatternDiscovery } from './UrlPatternDiscovery'; diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts index 36378798d4..280321daa0 100644 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts +++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { wait } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { OAuthPendingRequests } from './OAuthPendingRequests'; describe('OAuthPendingRequests', () => { @@ -27,7 +27,7 @@ describe('OAuthPendingRequests', () => { target.pending().subscribe({ next, error }); target.request(input); - await wait(() => expect(next).toBeCalledTimes(2)); + await waitFor(() => expect(next).toBeCalledTimes(2)); expect(next.mock.calls[0][0].scopes).toBeUndefined(); expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString()); expect(error.mock.calls.length).toBe(0); diff --git a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts new file mode 100644 index 0000000000..40e537169c --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Auth0Icon from '@material-ui/icons/AcUnit'; +import { auth0AuthApiRef } from '../../../definitions/auth'; +import { + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '../../../definitions'; +import { OAuth2 } from '../oauth2'; + +type CreateOptions = { + discoveryApi: DiscoveryApi; + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +const DEFAULT_PROVIDER = { + id: 'auth0', + title: 'Auth0', + icon: Auth0Icon, +}; + +class Auth0Auth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions): typeof auth0AuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes: ['openid', `email`, `profile`], + }); + } +} + +export default Auth0Auth; diff --git a/packages/core-api/src/apis/implementations/auth/auth0/index.ts b/packages/core-api/src/apis/implementations/auth/auth0/index.ts new file mode 100644 index 0000000000..dda27d0fa3 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/auth0/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as Auth0Auth } from './Auth0Auth'; diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index aaea681cef..8b9f807cd8 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -25,7 +25,11 @@ import { BackstageIdentity, AuthRequestOptions, } from '../../../definitions/auth'; -import { OAuthRequestApi, AuthProvider } from '../../../definitions'; +import { + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { AuthSessionStore, @@ -34,10 +38,7 @@ import { import { Observable } from '../../../../types'; type CreateOptions = { - // TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GithubAuth - apiOrigin: string; - basePath: string; - + discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; environment?: string; @@ -62,15 +63,13 @@ const DEFAULT_PROVIDER = { class GithubAuth implements OAuthApi, SessionStateApi { static create({ - apiOrigin, - basePath, + discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, }: CreateOptions) { const connector = new DefaultAuthConnector({ - apiOrigin, - basePath, + discoveryApi, environment, provider, oauthRequestApi: oauthRequestApi, diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts index 18346b3208..44b9aeb14e 100644 --- a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts @@ -14,33 +14,37 @@ * limitations under the License. */ +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; import GitlabAuth from './GitlabAuth'; -describe('GitlabAuth', () => { - it('should get access token', async () => { - const getSession = jest - .fn() - .mockResolvedValue({ providerInfo: { accessToken: 'access-token' } }); - const gitlabAuth = new GitlabAuth({ getSession } as any); +const getSession = jest.fn(); - expect(await gitlabAuth.getAccessToken()).toBe('access-token'); - expect(getSession).toBeCalledTimes(1); +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + +describe('GitlabAuth', () => { + afterEach(() => { + jest.resetAllMocks(); }); - it('should normalize scope', () => { - const tests = [ - { - arguments: ['read_user api write_repository'], - expect: new Set(['read_user', 'api', 'write_repository']), - }, - { - arguments: ['read_repository sudo'], - expect: new Set(['read_repository', 'sudo']), - }, - ]; + it.each([ + [ + 'read_user api write_repository', + ['read_user', 'api', 'write_repository'], + ], + ['read_repository sudo', ['read_repository', 'sudo']], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + const googleAuth = GitlabAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); - for (const test of tests) { - expect(GitlabAuth.normalizeScope(...test.arguments)).toEqual(test.expect); - } + googleAuth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); }); }); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts index 20e13f8a03..9e2acd4537 100644 --- a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -15,121 +15,42 @@ */ import GitlabIcon from '@material-ui/icons/AcUnit'; -import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; -import { GitlabSession } from './types'; +import { gitlabAuthApiRef } from '../../../definitions/auth'; import { - OAuthApi, - SessionStateApi, - SessionState, - ProfileInfo, - BackstageIdentity, - AuthRequestOptions, -} from '../../../definitions/auth'; -import { OAuthRequestApi, AuthProvider } from '../../../definitions'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager'; -import { Observable } from '../../../../types'; + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '../../../definitions'; +import { OAuth2 } from '../oauth2'; type CreateOptions = { - apiOrigin: string; - basePath: string; - + discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; environment?: string; provider?: AuthProvider & { id: string }; }; -export type GitlabAuthResponse = { - providerInfo: { - accessToken: string; - scope: string; - expiresInSeconds: number; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; - const DEFAULT_PROVIDER = { id: 'gitlab', title: 'Gitlab', icon: GitlabIcon, }; -class GitlabAuth implements OAuthApi, SessionStateApi { +class GitlabAuth { static create({ - apiOrigin, - basePath, + discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions) { - const connector = new DefaultAuthConnector({ - apiOrigin, - basePath, - environment, - provider, + }: CreateOptions): typeof gitlabAuthApiRef.T { + return OAuth2.create({ + discoveryApi, oauthRequestApi, - sessionTransform(res: GitlabAuthResponse): GitlabSession { - return { - ...res, - providerInfo: { - accessToken: res.providerInfo.accessToken, - scopes: GitlabAuth.normalizeScope(res.providerInfo.scope), - expiresAt: new Date( - Date.now() + res.providerInfo.expiresInSeconds * 1000, - ), - }, - }; - }, + provider, + environment, + defaultScopes: ['read_user'], }); - - const sessionManager = new StaticAuthSessionManager({ - connector, - defaultScopes: new Set(['read_user']), - sessionScopes: (session: GitlabSession) => session.providerInfo.scopes, - }); - - return new GitlabAuth(sessionManager); - } - - sessionState$(): Observable { - return this.sessionManager.sessionState$(); - } - - constructor(private readonly sessionManager: SessionManager) {} - - async getAccessToken(scope?: string, options?: AuthRequestOptions) { - const session = await this.sessionManager.getSession({ - ...options, - scopes: GitlabAuth.normalizeScope(scope), - }); - return session?.providerInfo.accessToken ?? ''; - } - - async getBackstageIdentity( - options: AuthRequestOptions = {}, - ): Promise { - const session = await this.sessionManager.getSession(options); - return session?.backstageIdentity; - } - - async getProfile(options: AuthRequestOptions = {}) { - const session = await this.sessionManager.getSession(options); - return session?.profile; - } - - async logout() { - await this.sessionManager.removeSession(); - } - - static normalizeScope(scope?: string): Set { - if (!scope) { - return new Set(); - } - - const scopeList = Array.isArray(scope) ? scope : scope.split(' '); - return new Set(scopeList); } } export default GitlabAuth; diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/index.ts b/packages/core-api/src/apis/implementations/auth/gitlab/index.ts index ee17b7de06..42d7210551 100644 --- a/packages/core-api/src/apis/implementations/auth/gitlab/index.ts +++ b/packages/core-api/src/apis/implementations/auth/gitlab/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export * from './types'; export { default as GitlabAuth } from './GitlabAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts index 5290ee798b..9e8569c5cf 100644 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts @@ -15,101 +15,23 @@ */ import GoogleAuth from './GoogleAuth'; - -const theFuture = new Date(Date.now() + 3600000); -const thePast = new Date(Date.now() - 10); +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; const PREFIX = 'https://www.googleapis.com/auth/'; +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + describe('GoogleAuth', () => { - it('should get refreshed access token', async () => { - const getSession = jest.fn().mockResolvedValue({ - providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, - }); - const googleAuth = new GoogleAuth({ getSession } as any); - - expect(await googleAuth.getAccessToken()).toBe('access-token'); - expect(getSession).toBeCalledTimes(1); - }); - - it('should get refreshed id token', async () => { - const getSession = jest.fn().mockResolvedValue({ - providerInfo: { idToken: 'id-token', expiresAt: theFuture }, - }); - const googleAuth = new GoogleAuth({ getSession } as any); - - expect(await googleAuth.getIdToken()).toBe('id-token'); - expect(getSession).toBeCalledTimes(1); - }); - - it('should get optional id token', async () => { - const getSession = jest.fn().mockResolvedValue({ - providerInfo: { idToken: 'id-token', expiresAt: theFuture }, - }); - const googleAuth = new GoogleAuth({ getSession } as any); - - expect(await googleAuth.getIdToken({ optional: true })).toBe('id-token'); - expect(getSession).toBeCalledTimes(1); - }); - - it('should share popup closed errors', async () => { - const error = new Error('NOPE'); - error.name = 'RejectedError'; - const getSession = jest - .fn() - .mockResolvedValueOnce({ - providerInfo: { - accessToken: 'access-token', - expiresAt: theFuture, - scopes: new Set([`${PREFIX}not-enough`]), - }, - }) - .mockRejectedValue(error); - const googleAuth = new GoogleAuth({ getSession } as any); - - // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check - await expect(googleAuth.getAccessToken()).resolves.toBe('access-token'); - - const promise1 = googleAuth.getAccessToken('more'); - const promise2 = googleAuth.getAccessToken('more'); - await expect(promise1).rejects.toBe(error); - await expect(promise2).rejects.toBe(error); - expect(getSession).toBeCalledTimes(3); - }); - - it('should wait for all session refreshes', async () => { - const initialSession = { - providerInfo: { - idToken: 'token1', - expiresAt: theFuture, - scopes: new Set(), - }, - }; - const getSession = jest - .fn() - .mockResolvedValueOnce(initialSession) - .mockResolvedValue({ - providerInfo: { - idToken: 'token2', - expiresAt: theFuture, - scopes: new Set(), - }, - }); - const googleAuth = new GoogleAuth({ getSession } as any); - - // Grab the expired session first - await expect(googleAuth.getIdToken()).resolves.toBe('token1'); - expect(getSession).toBeCalledTimes(1); - - initialSession.providerInfo.expiresAt = thePast; - - const promise1 = googleAuth.getIdToken(); - const promise2 = googleAuth.getIdToken(); - const promise3 = googleAuth.getIdToken(); - await expect(promise1).resolves.toBe('token2'); - await expect(promise2).resolves.toBe('token2'); - await expect(promise3).resolves.toBe('token2'); - expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client + afterEach(() => { + jest.resetAllMocks(); }); it.each([ @@ -136,6 +58,12 @@ describe('GoogleAuth', () => { [`${PREFIX}profile`, [`${PREFIX}profile`]], [`${PREFIX}openid`, [`${PREFIX}openid`]], ])(`should normalize scopes correctly - %p`, (scope, scopes) => { - expect(GoogleAuth.normalizeScopes(scope)).toEqual(new Set(scopes)); + const googleAuth = GoogleAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + googleAuth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); }); }); diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts index 594116fc1a..7e84226508 100644 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -15,171 +15,65 @@ */ import GoogleIcon from '@material-ui/icons/AcUnit'; -import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; -import { GoogleSession } from './types'; +import { googleAuthApiRef } from '../../../definitions/auth'; import { - OAuthApi, - OpenIdConnectApi, - ProfileInfoApi, - ProfileInfo, - SessionStateApi, - SessionState, - BackstageIdentityApi, - AuthRequestOptions, - BackstageIdentity, -} from '../../../definitions/auth'; -import { OAuthRequestApi, AuthProvider } from '../../../definitions'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; -import { Observable } from '../../../../types'; + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '../../../definitions'; +import { OAuth2 } from '../oauth2'; type CreateOptions = { - // TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GoogleAuth - apiOrigin: string; - basePath: string; - + discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; environment?: string; provider?: AuthProvider & { id: string }; }; -export type GoogleAuthResponse = { - providerInfo: { - accessToken: string; - idToken: string; - scope: string; - expiresInSeconds: number; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; - const DEFAULT_PROVIDER = { id: 'google', title: 'Google', icon: GoogleIcon, }; -const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; - -class GoogleAuth - implements - OAuthApi, - OpenIdConnectApi, - ProfileInfoApi, - BackstageIdentityApi, - SessionStateApi { +class GoogleAuth { static create({ - apiOrigin, - basePath, + discoveryApi, + oauthRequestApi, environment = 'development', provider = DEFAULT_PROVIDER, - oauthRequestApi, - }: CreateOptions) { - const connector = new DefaultAuthConnector({ - apiOrigin, - basePath, - environment, - provider, - oauthRequestApi: oauthRequestApi, - sessionTransform(res: GoogleAuthResponse): GoogleSession { - return { - ...res, - providerInfo: { - idToken: res.providerInfo.idToken, - accessToken: res.providerInfo.accessToken, - scopes: GoogleAuth.normalizeScopes(res.providerInfo.scope), - expiresAt: new Date( - Date.now() + res.providerInfo.expiresInSeconds * 1000, - ), - }, - }; - }, - }); + }: CreateOptions): typeof googleAuthApiRef.T { + const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; - const sessionManager = new RefreshingAuthSessionManager({ - connector, - defaultScopes: new Set([ + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes: [ 'openid', `${SCOPE_PREFIX}userinfo.email`, `${SCOPE_PREFIX}userinfo.profile`, - ]), - sessionScopes: (session: GoogleSession) => session.providerInfo.scopes, - sessionShouldRefresh: (session: GoogleSession) => { - const expiresInSec = - (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; - return expiresInSec < 60 * 5; + ], + scopeTransform(scopes: string[]) { + return scopes.map(scope => { + if (scope === 'openid') { + return scope; + } + + if (scope === 'profile' || scope === 'email') { + return `${SCOPE_PREFIX}userinfo.${scope}`; + } + + if (scope.startsWith(SCOPE_PREFIX)) { + return scope; + } + + return `${SCOPE_PREFIX}${scope}`; + }); }, }); - - return new GoogleAuth(sessionManager); - } - - sessionState$(): Observable { - return this.sessionManager.sessionState$(); - } - - constructor(private readonly sessionManager: SessionManager) {} - - async getAccessToken( - scope?: string | string[], - options?: AuthRequestOptions, - ) { - const session = await this.sessionManager.getSession({ - ...options, - scopes: GoogleAuth.normalizeScopes(scope), - }); - return session?.providerInfo.accessToken ?? ''; - } - - async getIdToken(options: AuthRequestOptions = {}) { - const session = await this.sessionManager.getSession(options); - return session?.providerInfo.idToken ?? ''; - } - - async logout() { - await this.sessionManager.removeSession(); - } - - async getBackstageIdentity( - options: AuthRequestOptions = {}, - ): Promise { - const session = await this.sessionManager.getSession(options); - return session?.backstageIdentity; - } - - async getProfile(options: AuthRequestOptions = {}) { - const session = await this.sessionManager.getSession(options); - return session?.profile; - } - - static normalizeScopes(scopes?: string | string[]): Set { - if (!scopes) { - return new Set(); - } - - const scopeList = Array.isArray(scopes) - ? scopes - : scopes.split(/[\s]/).filter(Boolean); - - const normalizedScopes = scopeList.map(scope => { - if (scope === 'openid') { - return scope; - } - - if (scope === 'profile' || scope === 'email') { - return `${SCOPE_PREFIX}userinfo.${scope}`; - } - - if (scope.startsWith(SCOPE_PREFIX)) { - return scope; - } - - return `${SCOPE_PREFIX}${scope}`; - }); - - return new Set(normalizedScopes); } } export default GoogleAuth; diff --git a/packages/core-api/src/apis/implementations/auth/google/index.ts b/packages/core-api/src/apis/implementations/auth/google/index.ts index 78e8a97c31..2521d46046 100644 --- a/packages/core-api/src/apis/implementations/auth/google/index.ts +++ b/packages/core-api/src/apis/implementations/auth/google/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export * from './types'; export { default as GoogleAuth } from './GoogleAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index 786e9fa771..a6d7e2c989 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -19,3 +19,5 @@ export * from './gitlab'; export * from './google'; export * from './oauth2'; export * from './okta'; +export * from './auth0'; +export * from './microsoft'; diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts new file mode 100644 index 0000000000..241ac7b802 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MicrosoftIcon from '@material-ui/icons/AcUnit'; +import { microsoftAuthApiRef } from '../../../definitions/auth'; + +import { + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '../../../definitions'; +import { OAuth2 } from '../oauth2'; + +type CreateOptions = { + discoveryApi: DiscoveryApi; + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +const DEFAULT_PROVIDER = { + id: 'microsoft', + title: 'Microsoft', + icon: MicrosoftIcon, +}; + +class MicrosoftAuth { + static create({ + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + discoveryApi, + }: CreateOptions): typeof microsoftAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes: [ + 'openid', + 'offline_access', + 'profile', + 'email', + 'User.Read', + ], + }); + } +} + +export default MicrosoftAuth; diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/index.ts b/packages/core-api/src/apis/implementations/auth/microsoft/index.ts new file mode 100644 index 0000000000..77328d8557 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/microsoft/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default as MicrosoftAuth } from './MicrosoftAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts new file mode 100644 index 0000000000..93db2c732d --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import OAuth2 from './OAuth2'; + +const theFuture = new Date(Date.now() + 3600000); +const thePast = new Date(Date.now() - 10); + +const PREFIX = 'https://www.googleapis.com/auth/'; + +const scopeTransform = (x: string[]) => x; + +describe('OAuth2', () => { + it('should get refreshed access token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe( + 'access-token', + ); + expect(getSession).toBeCalledTimes(1); + expect(getSession.mock.calls[0][0].scopes).toEqual( + new Set(['my-scope', 'my-scope2']), + ); + }); + + it('should transform scopes', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + }); + + expect(await oauth2.getAccessToken('my-scope')).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + expect(getSession.mock.calls[0][0].scopes).toEqual( + new Set(['my-prefix/my-scope']), + ); + }); + + it('should get refreshed id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.getIdToken()).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should get optional id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.getIdToken({ optional: true })).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should share popup closed errors', async () => { + const error = new Error('NOPE'); + error.name = 'RejectedError'; + const getSession = jest + .fn() + .mockResolvedValueOnce({ + providerInfo: { + accessToken: 'access-token', + expiresAt: theFuture, + scopes: new Set([`${PREFIX}not-enough`]), + }, + }) + .mockRejectedValue(error); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check + await expect(oauth2.getAccessToken()).resolves.toBe('access-token'); + + const promise1 = oauth2.getAccessToken('more'); + const promise2 = oauth2.getAccessToken('more'); + await expect(promise1).rejects.toBe(error); + await expect(promise2).rejects.toBe(error); + expect(getSession).toBeCalledTimes(3); + }); + + it('should wait for all session refreshes', async () => { + const initialSession = { + providerInfo: { + idToken: 'token1', + expiresAt: theFuture, + scopes: new Set(), + }, + }; + const getSession = jest + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValue({ + providerInfo: { + idToken: 'token2', + expiresAt: theFuture, + scopes: new Set(), + }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + // Grab the expired session first + await expect(oauth2.getIdToken()).resolves.toBe('token1'); + expect(getSession).toBeCalledTimes(1); + + initialSession.providerInfo.expiresAt = thePast; + + const promise1 = oauth2.getIdToken(); + const promise2 = oauth2.getIdToken(); + const promise3 = oauth2.getIdToken(); + await expect(promise1).resolves.toBe('token2'); + await expect(promise2).resolves.toBe('token2'); + await expect(promise3).resolves.toBe('token2'); + expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client + }); +}); diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts index d2a3531d05..27626aac5a 100644 --- a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -19,7 +19,11 @@ import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { Observable } from '../../../../types'; -import { AuthProvider, OAuthRequestApi } from '../../../definitions'; +import { + AuthProvider, + OAuthRequestApi, + DiscoveryApi, +} from '../../../definitions'; import { AuthRequestOptions, BackstageIdentity, @@ -29,17 +33,23 @@ import { ProfileInfoApi, SessionState, SessionStateApi, + BackstageIdentityApi, } from '../../../definitions/auth'; import { OAuth2Session } from './types'; -type CreateOptions = { - apiOrigin: string; - basePath: string; +type Options = { + sessionManager: SessionManager; + scopeTransform: (scopes: string[]) => string[]; +}; +type CreateOptions = { + discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; environment?: string; provider?: AuthProvider & { id: string }; + defaultScopes?: string[]; + scopeTransform?: (scopes: string[]) => string[]; }; export type OAuth2Response = { @@ -59,20 +69,23 @@ const DEFAULT_PROVIDER = { icon: OAuth2Icon, }; -const SCOPE_PREFIX = ''; - class OAuth2 - implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi { + implements + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + BackstageIdentityApi, + SessionStateApi { static create({ - apiOrigin, - basePath, + discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, + defaultScopes = [], + scopeTransform = x => x, }: CreateOptions) { const connector = new DefaultAuthConnector({ - apiOrigin, - basePath, + discoveryApi, environment, provider, oauthRequestApi: oauthRequestApi, @@ -82,7 +95,10 @@ class OAuth2 providerInfo: { idToken: res.providerInfo.idToken, accessToken: res.providerInfo.accessToken, - scopes: OAuth2.normalizeScopes(res.providerInfo.scope), + scopes: OAuth2.normalizeScopes( + scopeTransform, + res.providerInfo.scope, + ), expiresAt: new Date( Date.now() + res.providerInfo.expiresInSeconds * 1000, ), @@ -93,11 +109,7 @@ class OAuth2 const sessionManager = new RefreshingAuthSessionManager({ connector, - defaultScopes: new Set([ - 'openid', - `${SCOPE_PREFIX}userinfo.email`, - `${SCOPE_PREFIX}userinfo.profile`, - ]), + defaultScopes: new Set(defaultScopes), sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes, sessionShouldRefresh: (session: OAuth2Session) => { const expiresInSec = @@ -106,20 +118,26 @@ class OAuth2 }, }); - return new OAuth2(sessionManager); + return new OAuth2({ sessionManager, scopeTransform }); + } + + private readonly sessionManager: SessionManager; + private readonly scopeTransform: (scopes: string[]) => string[]; + + constructor(options: Options) { + this.sessionManager = options.sessionManager; + this.scopeTransform = options.scopeTransform; } sessionState$(): Observable { return this.sessionManager.sessionState$(); } - constructor(private readonly sessionManager: SessionManager) {} - async getAccessToken( scope?: string | string[], options?: AuthRequestOptions, ) { - const normalizedScopes = OAuth2.normalizeScopes(scope); + const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope); const session = await this.sessionManager.getSession({ ...options, scopes: normalizedScopes, @@ -148,16 +166,19 @@ class OAuth2 return session?.profile; } - static normalizeScopes(scopes?: string | string[]): Set { + private static normalizeScopes( + scopeTransform: (scopes: string[]) => string[], + scopes?: string | string[], + ): Set { if (!scopes) { return new Set(); } const scopeList = Array.isArray(scopes) ? scopes - : scopes.split(/[\s]/).filter(Boolean); + : scopes.split(/[\s|,]/).filter(Boolean); - return new Set(scopeList); + return new Set(scopeTransform(scopeList)); } } diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts index ab6c46c9b4..d6b1a07d9d 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts @@ -13,102 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import OktaAuth from './OktaAuth'; -const theFuture = new Date(Date.now() + 3600000); -const thePast = new Date(Date.now() - 10); +import OktaAuth from './OktaAuth'; +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; const PREFIX = 'okta.'; +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + describe('OktaAuth', () => { - it('should get refreshed access token', async () => { - const getSession = jest.fn().mockResolvedValue({ - providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, - }); - const oktaAuth = new OktaAuth({ getSession } as any); - - expect(await oktaAuth.getAccessToken()).toBe('access-token'); - expect(getSession).toBeCalledTimes(1); - }); - - it('should get refreshed id token', async () => { - const getSession = jest.fn().mockResolvedValue({ - providerInfo: { idToken: 'id-token', expiresAt: theFuture }, - }); - const oktaAuth = new OktaAuth({ getSession } as any); - - expect(await oktaAuth.getIdToken()).toBe('id-token'); - expect(getSession).toBeCalledTimes(1); - }); - - it('should get optional id token', async () => { - const getSession = jest.fn().mockResolvedValue({ - providerInfo: { idToken: 'id-token', expiresAt: theFuture }, - }); - const oktaAuth = new OktaAuth({ getSession } as any); - - expect(await oktaAuth.getIdToken({ optional: true })).toBe('id-token'); - expect(getSession).toBeCalledTimes(1); - }); - - it('should share popup closed errors', async () => { - const error = new Error('NOPE'); - error.name = 'RejectedError'; - const getSession = jest - .fn() - .mockResolvedValueOnce({ - providerInfo: { - accessToken: 'access-token', - expiresAt: theFuture, - scopes: new Set([`not-a-scope`]), - }, - }) - .mockRejectedValue(error); - const oktaAuth = new OktaAuth({ getSession } as any); - - // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check - await expect(oktaAuth.getAccessToken()).resolves.toBe('access-token'); - - const promise1 = oktaAuth.getAccessToken('more'); - const promise2 = oktaAuth.getAccessToken('more'); - await expect(promise1).rejects.toBe(error); - await expect(promise2).rejects.toBe(error); - expect(getSession).toBeCalledTimes(3); - }); - - it('should wait for all session refreshes', async () => { - const initialSession = { - providerInfo: { - idToken: 'token1', - expiresAt: theFuture, - scopes: new Set(), - }, - }; - const getSession = jest - .fn() - .mockResolvedValueOnce(initialSession) - .mockResolvedValue({ - providerInfo: { - idToken: 'token2', - expiresAt: theFuture, - scopes: new Set(), - }, - }); - const oktaAuth = new OktaAuth({ getSession } as any); - - // Grab the expired session first - await expect(oktaAuth.getIdToken()).resolves.toBe('token1'); - expect(getSession).toBeCalledTimes(1); - - initialSession.providerInfo.expiresAt = thePast; - - const promise1 = oktaAuth.getIdToken(); - const promise2 = oktaAuth.getIdToken(); - const promise3 = oktaAuth.getIdToken(); - await expect(promise1).resolves.toBe('token2'); - await expect(promise2).resolves.toBe('token2'); - await expect(promise3).resolves.toBe('token2'); - expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client + afterEach(() => { + jest.resetAllMocks(); }); it.each([ @@ -116,7 +39,10 @@ describe('OktaAuth', () => { ['profile email', ['profile', 'email']], [`${PREFIX}groups.manage`, [`${PREFIX}groups.manage`]], ['groups.read', [`${PREFIX}groups.read`]], - [`${PREFIX}groups.manage groups.read, openid`, [`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid']], + [ + `${PREFIX}groups.manage groups.read, openid`, + [`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid'], + ], [`email\t ${PREFIX}groups.read`, ['email', `${PREFIX}groups.read`]], // Some incorrect scopes that we don't try to fix @@ -124,6 +50,12 @@ describe('OktaAuth', () => { [`${PREFIX}profile`, [`${PREFIX}profile`]], [`${PREFIX}openid`, [`${PREFIX}openid`]], ])(`should normalize scopes correctly - %p`, (scope, scopes) => { - expect(OktaAuth.normalizeScopes(scope)).toEqual(new Set(scopes)); + const auth = OktaAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + auth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); }); -}); +}); diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts index f10ec4ebce..7e9ff77678 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -15,172 +15,68 @@ */ import OktaIcon from '@material-ui/icons/AcUnit'; -import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; -import { OktaSession } from './types'; +import { oktaAuthApiRef } from '../../../definitions/auth'; import { - OAuthApi, - OpenIdConnectApi, - ProfileInfoApi, - ProfileInfo, - SessionStateApi, - SessionState, - BackstageIdentityApi, - AuthRequestOptions, - BackstageIdentity, -} from '../../../definitions/auth'; -import { OAuthRequestApi, AuthProvider } from '../../../definitions'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; -import { Observable } from '../../../../types'; + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '../../../definitions'; +import { OAuth2 } from '../oauth2'; type CreateOptions = { - apiOrigin: string; - basePath: string; - + discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; environment?: string; provider?: AuthProvider & { id: string }; }; -export type OktaAuthResponse = { - providerInfo: { - accessToken: string; - idToken: string; - scope: string; - expiresInSeconds: number; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; - const DEFAULT_PROVIDER = { id: 'okta', title: 'Okta', icon: OktaIcon, }; -const OKTA_OIDC_SCOPES: Set = new Set( - ['openid', 'profile', 'email', 'phone', 'address', 'groups', 'offline_access'] -) +const OKTA_OIDC_SCOPES: Set = new Set([ + 'openid', + 'profile', + 'email', + 'phone', + 'address', + 'groups', + 'offline_access', +]); -const OKTA_SCOPE_PREFIX: string = 'okta.' +const OKTA_SCOPE_PREFIX: string = 'okta.'; -class OktaAuth implements - OAuthApi, - OpenIdConnectApi, - ProfileInfoApi, - BackstageIdentityApi, - SessionStateApi -{ +class OktaAuth { static create({ - apiOrigin, - basePath, + discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions) { - const connector = new DefaultAuthConnector({ - apiOrigin, - basePath, - environment, + }: CreateOptions): typeof oktaAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, provider, - oauthRequestApi: oauthRequestApi, - sessionTransform(res: OktaAuthResponse): OktaSession { - return { - ...res, - providerInfo: { - idToken: res.providerInfo.idToken, - accessToken: res.providerInfo.accessToken, - scopes: OktaAuth.normalizeScopes(res.providerInfo.scope), - expiresAt: new Date( - Date.now() + res.providerInfo.expiresInSeconds * 1000, - ), - }, - }; + environment, + defaultScopes: ['openid', 'email', 'profile', 'offline_access'], + scopeTransform(scopes) { + return scopes.map(scope => { + if (OKTA_OIDC_SCOPES.has(scope)) { + return scope; + } + + if (scope.startsWith(OKTA_SCOPE_PREFIX)) { + return scope; + } + + return `${OKTA_SCOPE_PREFIX}${scope}`; + }); }, }); - - const sessionManager = new RefreshingAuthSessionManager({ - connector, - defaultScopes: new Set([ - 'openid', - 'email', - 'profile', - 'offline_access', - ]), - sessionScopes: session => session.scopes, - sessionShouldRefresh: session => { - const expiresInSec = - (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; - return expiresInSec < 60 * 5; - }, - }); - - return new OktaAuth(sessionManager); - } - - sessionState$(): Observable { - return this.sessionManager.sessionState$(); - } - - constructor(private readonly sessionManager: SessionManager) {} - - async getAccessToken( - scope?: string, - options?: AuthRequestOptions - ) { - const session = await this.sessionManager.getSession({ - ...options, - scopes: OktaAuth.normalizeScopes(scope), - }); - return session?.providerInfo.accessToken ?? ''; - } - - async getIdToken(options: AuthRequestOptions = {}) { - const session = await this.sessionManager.getSession(options); - return session?.providerInfo.idToken ?? ''; - } - - async logout() { - await this.sessionManager.removeSession(); - } - - async getBackstageIdentity( - options: AuthRequestOptions = {}, - ): Promise { - const session = await this.sessionManager.getSession(options); - return session?.backstageIdentity; - } - - async getProfile(options: AuthRequestOptions = {}) { - const session = await this.sessionManager.getSession(options); - return session?.profile; - } - - static normalizeScopes(scopes?: string | string[]): Set { - if (!scopes) { - return new Set(); - } - - const scopeList = Array.isArray(scopes) - ? scopes - : scopes.split(/[\s|,]/).filter(Boolean); - - const normalizedScopes = scopeList.map(scope => { - if (OKTA_OIDC_SCOPES.has(scope)) { - return scope; - } - - if (scope.startsWith(OKTA_SCOPE_PREFIX)) { - return scope; - } - - return `${OKTA_SCOPE_PREFIX}${scope}` - }); - - return new Set(normalizedScopes); } } -export default OktaAuth; \ No newline at end of file +export default OktaAuth; diff --git a/packages/core-api/src/apis/implementations/auth/okta/index.ts b/packages/core-api/src/apis/implementations/auth/okta/index.ts index 2bef0ce0db..4cc774b26b 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/index.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export * from './types'; -export { default as OktaAuth } from './OktaAuth'; +export { default as OktaAuth } from './OktaAuth'; diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts index e6d23fee21..30aeb81d44 100644 --- a/packages/core-api/src/apis/implementations/index.ts +++ b/packages/core-api/src/apis/implementations/index.ts @@ -24,5 +24,6 @@ export * from './AlertApi'; export * from './AppThemeApi'; export * from './ConfigApi'; export * from './ErrorApi'; +export * from './DiscoveryApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; diff --git a/packages/core-api/src/apis/index.ts b/packages/core-api/src/apis/index.ts index c3689b6703..c856a13668 100644 --- a/packages/core-api/src/apis/index.ts +++ b/packages/core-api/src/apis/index.ts @@ -16,7 +16,6 @@ export { ApiProvider, useApi, useApiHolder } from './ApiProvider'; export { ApiRegistry } from './ApiRegistry'; -export { ApiTestRegistry } from './ApiTestRegistry'; export * from './ApiRef'; export * from './types'; export * from './helpers'; diff --git a/packages/core-api/src/apis/types.ts b/packages/core-api/src/apis/types.ts index 4ee9b39ca9..61c229b18e 100644 --- a/packages/core-api/src/apis/types.ts +++ b/packages/core-api/src/apis/types.ts @@ -16,13 +16,13 @@ import { ApiRef } from './ApiRef'; -export type AnyApiRef = ApiRef; +export type AnyApiRef = ApiRef; export type ApiRefType = T extends ApiRef ? U : never; export type TypesToApiRefs = { [key in keyof T]: ApiRef }; -export type ApiRefsToTypes }> = { +export type ApiRefsToTypes }> = { [key in keyof T]: ApiRefType; }; @@ -30,8 +30,16 @@ export type ApiHolder = { get(api: ApiRef): T | undefined; }; -export type ApiFactory = { - implements: ApiRef; +export type ApiFactory = { + api: ApiRef; deps: TypesToApiRefs; - factory(deps: Deps): Impl extends Api ? Impl : never; + factory(deps: Deps): Api; +}; + +export type AnyApiFactory = ApiFactory; + +export type ApiFactoryHolder = { + get( + api: ApiRef, + ): ApiFactory | undefined; }; diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 27a63ea04a..9dd2fb138f 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -26,7 +26,6 @@ import { BackstageApp, AppComponents, AppConfigLoader, - Apis, SignInResult, SignInPageProps, } from './types'; @@ -42,7 +41,6 @@ import { AppThemeProvider } from './AppThemeProvider'; import { IconComponent, SystemIcons, SystemIconKey } from '../icons'; import { - ApiHolder, ApiProvider, ApiRegistry, AppTheme, @@ -51,18 +49,22 @@ import { configApiRef, ConfigReader, useApi, + AnyApiFactory, + ApiHolder, } from '../apis'; -import { ApiAggregator } from '../apis/ApiAggregator'; import { useAsync } from 'react-use'; import { AppIdentity } from './AppIdentity'; +import { ApiFactoryRegistry } from '../apis/ApiFactoryRegistry'; +import { ApiResolver } from '../apis/ApiResolver'; type FullAppOptions = { - apis: Apis; + apis: Iterable; icons: SystemIcons; plugins: BackstagePlugin[]; components: AppComponents; themes: AppTheme[]; configLoader?: AppConfigLoader; + defaultApis: Iterable; }; function useConfigLoader( @@ -101,31 +103,27 @@ function useConfigLoader( } export class PrivateAppImpl implements BackstageApp { - private apis?: ApiHolder = undefined; + private apiHolder?: ApiHolder; + private configApi?: ConfigApi; + + private readonly apis: Iterable; private readonly icons: SystemIcons; private readonly plugins: BackstagePlugin[]; private readonly components: AppComponents; private readonly themes: AppTheme[]; private readonly configLoader?: AppConfigLoader; + private readonly defaultApis: Iterable; private readonly identityApi = new AppIdentity(); - private apisOrFactory: Apis; - constructor(options: FullAppOptions) { - this.apisOrFactory = options.apis; + this.apis = options.apis; this.icons = options.icons; this.plugins = options.plugins; this.components = options.components; this.themes = options.themes; this.configLoader = options.configLoader; - } - - getApis(): ApiHolder { - if (!this.apis) { - throw new Error('Tried to access APIs before app was loaded'); - } - return this.apis; + this.defaultApis = options.defaultApis; } getPlugins(): BackstagePlugin[] { @@ -136,7 +134,7 @@ export class PrivateAppImpl implements BackstageApp { return this.icons[key]; } - getRoutes(): ComponentType<{}> { + getRoutes(): JSX.Element[] { const routes = new Array(); const registeredFeatureFlags = new Array(); @@ -186,19 +184,14 @@ export class PrivateAppImpl implements BackstageApp { } } - const FeatureFlags = this.apis && this.apis.get(featureFlagsApiRef); - if (FeatureFlags) { - FeatureFlags.registeredFeatureFlags = registeredFeatureFlags; + const featureFlags = this.getApiHolder().get(featureFlagsApiRef); + if (featureFlags) { + featureFlags.registeredFeatureFlags = registeredFeatureFlags; } - const rendered = ( - - {routes} - } /> - - ); + routes.push(} />); - return () => rendered; + return routes; } getProvider(): ComponentType<{}> { @@ -215,28 +208,14 @@ export class PrivateAppImpl implements BackstageApp { ); if ('node' in loadedConfig) { + // Loading or error return loadedConfig.node; } - const configApi = loadedConfig.api; - const appApis = ApiRegistry.from([ - [appThemeApiRef, appThemeApi], - [configApiRef, configApi], - [identityApiRef, this.identityApi], - ]); - - if (!this.apis) { - if ('get' in this.apisOrFactory) { - this.apis = this.apisOrFactory; - } else { - this.apis = this.apisOrFactory(configApi); - } - } - - const apis = new ApiAggregator(this.apis, appApis); + this.configApi = loadedConfig.api; return ( - + {children} @@ -311,6 +290,67 @@ export class PrivateAppImpl implements BackstageApp { return AppRouter; } + private getApiHolder(): ApiHolder { + if (this.apiHolder) { + return this.apiHolder; + } + + const registry = new ApiFactoryRegistry(); + + registry.register('static', { + api: appThemeApiRef, + deps: {}, + factory: () => AppThemeSelector.createWithStorage(this.themes), + }); + registry.register('static', { + api: configApiRef, + deps: {}, + factory: () => { + if (!this.configApi) { + throw new Error( + 'Tried to access config API before config was loaded', + ); + } + return this.configApi; + }, + }); + registry.register('static', { + api: identityApiRef, + deps: {}, + factory: () => this.identityApi, + }); + + for (const factory of this.defaultApis) { + registry.register('default', factory); + } + + for (const plugin of this.plugins) { + for (const factory of plugin.getApis()) { + if (!registry.register('default', factory)) { + throw new Error( + `Plugin ${plugin.getId()} tried to register duplicate or forbidden API factory for ${ + factory.api + }`, + ); + } + } + } + + for (const factory of this.apis) { + if (!registry.register('app', factory)) { + throw new Error( + `Duplicate or forbidden API factory for ${factory.api} in app`, + ); + } + } + + ApiResolver.validateFactories(registry, registry.getAllApis()); + + this.apiHolder = new ApiResolver(registry); + + return this.apiHolder; + } + verify() { const pluginIds = new Set(); diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 6b022c2736..565e073aed 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -17,8 +17,8 @@ import { ComponentType } from 'react'; import { IconComponent, SystemIconKey, SystemIcons } from '../icons'; import { BackstagePlugin } from '../plugin'; -import { ApiHolder } from '../apis'; -import { AppTheme, ConfigApi, ProfileInfo } from '../apis/definitions'; +import { AnyApiFactory } from '../apis'; +import { AppTheme, ProfileInfo } from '../apis/definitions'; import { AppConfig } from '@backstage/config'; export type BootErrorPageProps = { @@ -77,16 +77,12 @@ export type AppComponents = { */ export type AppConfigLoader = () => Promise; -// TODO(Rugvip): Temporary workaround for accessing config when instantiating APIs, we might want to do this differently -export type Apis = ApiHolder | ((config: ConfigApi) => ApiHolder); - export type AppOptions = { /** - * A holder of all APIs available in the app. - * - * Use for example ApiRegistry or ApiTestRegistry. + * A collection of ApiFactories to register in the application to either + * add add new ones, or override factories provided by default or by plugins. */ - apis?: Apis; + apis?: Iterable; /** * Supply icons to override the default ones. @@ -138,11 +134,6 @@ export type AppOptions = { }; export type BackstageApp = { - /** - * Get the holder for all APIs available in the app. - */ - getApis(): ApiHolder; - /** * Returns all plugins registered for the app. */ @@ -168,5 +159,5 @@ export type BackstageApp = { /** * Routes component that contains all routes for plugin pages in the app. */ - getRoutes(): ComponentType<{}>; + getRoutes(): JSX.Element[]; }; diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts index bfbb2dbc0b..5781130799 100644 --- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts +++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -18,11 +18,12 @@ import ProviderIcon from '@material-ui/icons/AcUnit'; import { DefaultAuthConnector } from './DefaultAuthConnector'; import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi'; import * as loginPopup from '../loginPopup'; +import { UrlPatternDiscovery } from '../../apis'; const anyFetch = fetch as any; const defaultOptions = { - apiOrigin: 'my-origin', + discoveryApi: UrlPatternDiscovery.compile('http://my-host/api/{{pluginId}}'), environment: 'production', provider: { id: 'my-provider', @@ -114,7 +115,8 @@ describe('DefaultAuthConnector', () => { expect(popupSpy).toBeCalledTimes(1); expect(popupSpy.mock.calls[0][0]).toMatchObject({ - url: 'my-origin/api/auth/my-provider/start?scope=a%20b&env=production', + url: + 'http://my-host/api/auth/my-provider/start?scope=a%20b&env=production', }); await expect(sessionPromise).resolves.toEqual({ @@ -140,9 +142,9 @@ describe('DefaultAuthConnector', () => { instantPopup: true, }); - expect(popupSpy).toBeCalledTimes(1); - await expect(sessionPromise).resolves.toBe('my-session'); + + expect(popupSpy).toBeCalledTimes(1); }); it('should use join func to join scopes', async () => { @@ -162,7 +164,8 @@ describe('DefaultAuthConnector', () => { expect(popupSpy).toBeCalledTimes(1); expect(popupSpy.mock.calls[0][0]).toMatchObject({ - url: 'my-origin/api/auth/my-provider/start?scope=-ab-&env=production', + url: + 'http://my-host/api/auth/my-provider/start?scope=-ab-&env=production', }); }); }); diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts index f0f57c1c12..1c7e92daa8 100644 --- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -15,21 +15,19 @@ */ import { AuthRequester } from '../../apis'; -import { OAuthRequestApi, AuthProvider } from '../../apis/definitions'; +import { + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '../../apis/definitions'; import { showLoginPopup } from '../loginPopup'; import { AuthConnector, CreateSessionOptions } from './types'; -const DEFAULT_BASE_PATH = '/api/auth/'; - type Options = { /** - * Origin of auth requests, defaults to location.origin + * DiscoveryApi instance used to locate the auth backend endpoint. */ - apiOrigin?: string; - /** - * Base path of the auth requests, defaults to /api/auth/ - */ - basePath?: string; + discoveryApi: DiscoveryApi; /** * Environment hint passed on to auth backend, for example 'production' or 'development' */ @@ -64,8 +62,7 @@ function defaultJoinScopes(scopes: Set) { */ export class DefaultAuthConnector implements AuthConnector { - private readonly apiOrigin: string; - private readonly basePath: string; + private readonly discoveryApi: DiscoveryApi; private readonly environment: string; private readonly provider: AuthProvider & { id: string }; private readonly joinScopesFunc: (scopes: Set) => string; @@ -74,8 +71,7 @@ export class DefaultAuthConnector constructor(options: Options) { const { - apiOrigin = window.location.origin, - basePath = DEFAULT_BASE_PATH, + discoveryApi, environment, provider, joinScopes = defaultJoinScopes, @@ -88,8 +84,7 @@ export class DefaultAuthConnector onAuthRequest: scopes => this.showPopup(scopes), }); - this.apiOrigin = apiOrigin; - this.basePath = basePath; + this.discoveryApi = discoveryApi; this.environment = environment; this.provider = provider; this.joinScopesFunc = joinScopes; @@ -104,12 +99,15 @@ export class DefaultAuthConnector } async refreshSession(): Promise { - const res = await fetch(this.buildUrl('/refresh', { optional: true }), { - headers: { - 'x-requested-with': 'XMLHttpRequest', + const res = await fetch( + await this.buildUrl('/refresh', { optional: true }), + { + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', }, - credentials: 'include', - }).catch(error => { + ).catch(error => { throw new Error(`Auth refresh request failed, ${error}`); }); @@ -134,7 +132,7 @@ export class DefaultAuthConnector } async removeSession(): Promise { - const res = await fetch(this.buildUrl('/logout'), { + const res = await fetch(await this.buildUrl('/logout'), { method: 'POST', headers: { 'x-requested-with': 'XMLHttpRequest', @@ -153,12 +151,12 @@ export class DefaultAuthConnector private async showPopup(scopes: Set): Promise { const scope = this.joinScopesFunc(scopes); - const popupUrl = this.buildUrl('/start', { scope }); + const popupUrl = await this.buildUrl('/start', { scope }); const payload = await showLoginPopup({ url: popupUrl, name: `${this.provider.title} Login`, - origin: this.apiOrigin, + origin: new URL(popupUrl).origin, width: 450, height: 730, }); @@ -166,16 +164,17 @@ export class DefaultAuthConnector return await this.sessionTransform(payload); } - private buildUrl( + private async buildUrl( path: string, query?: { [key: string]: string | boolean | undefined }, - ): string { + ): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('auth'); const queryString = this.buildQueryString({ ...query, env: this.environment, }); - return `${this.apiOrigin}${this.basePath}${this.provider.id}${path}${queryString}`; + return `${baseUrl}/${this.provider.id}${path}${queryString}`; } private buildQueryString(query?: { diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx index bf98919c2b..1835ec03ad 100644 --- a/packages/core-api/src/plugin/Plugin.tsx +++ b/packages/core-api/src/plugin/Plugin.tsx @@ -24,9 +24,11 @@ import { } from './types'; import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags'; import { RouteRef } from '../routing'; +import { AnyApiFactory } from '../apis'; export type PluginConfig = { id: string; + apis?: Iterable; register?(hooks: PluginHooks): void; }; @@ -65,6 +67,10 @@ export class PluginImpl { return this.config.id; } + getApis(): Iterable { + return this.config.apis ?? []; + } + output(): PluginOutput[] { if (this.storedOutput) { return this.storedOutput; diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index fabe97434e..12992f3620 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -16,6 +16,7 @@ import { ComponentType } from 'react'; import { RouteRef } from '../routing'; +import { AnyApiFactory } from '../apis'; export type RouteOptions = { // Whether the route path must match exactly, defaults to true. @@ -70,4 +71,5 @@ export type PluginOutput = export type BackstagePlugin = { getId(): string; output(): PluginOutput[]; + getApis(): Iterable; }; diff --git a/packages/core/package.json b/packages/core/package.json index f75df3f564..7e9932bdc1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.18", - "@backstage/core-api": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/config": "^0.1.1-alpha.21", + "@backstage/core-api": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@types/react": "^16.9", @@ -40,22 +40,22 @@ "classnames": "^2.2.6", "clsx": "^1.1.0", "lodash": "^4.17.15", - "material-table": "1.68.x", + "material-table": "1.68.0", "prop-types": "^15.7.2", "rc-progress": "^3.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", "react-helmet": "6.1.0", - "react-hook-form": "^5.7.2", + "react-hook-form": "^6.6.0", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-sparklines": "^1.7.0", - "react-syntax-highlighter": "^13.2.1", + "react-syntax-highlighter": "^13.5.1", "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/test-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", @@ -63,7 +63,7 @@ "@types/google-protobuf": "^3.7.2", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "@types/react-helmet": "^5.0.15", + "@types/react-helmet": "^6.1.0", "@types/zen-observable": "^0.8.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/packages/core/src/api-wrappers/createApp.test.tsx b/packages/core/src/api-wrappers/createApp.test.tsx index 33623b4869..a1655b88dd 100644 --- a/packages/core/src/api-wrappers/createApp.test.tsx +++ b/packages/core/src/api-wrappers/createApp.test.tsx @@ -15,21 +15,21 @@ */ import { defaultConfigLoader } from './createApp'; -import { AppConfig } from '@backstage/config'; + +(process as any).env = { NODE_ENV: 'test' }; +const anyEnv = process.env as any; describe('defaultConfigLoader', () => { afterEach(() => { - delete process.env.APP_CONFIG; + delete anyEnv.APP_CONFIG; }); it('loads static config', async () => { - Object.defineProperty(process.env, 'APP_CONFIG', { - configurable: true, - value: [ - { data: { my: 'config' }, context: 'a' }, - { data: { my: 'override-config' }, context: 'b' }, - ] as AppConfig[], - }); + anyEnv.APP_CONFIG = [ + { data: { my: 'config' }, context: 'a' }, + { data: { my: 'override-config' }, context: 'b' }, + ]; + const configs = await defaultConfigLoader(); expect(configs).toEqual([ { data: { my: 'config' }, context: 'a' }, @@ -38,13 +38,11 @@ describe('defaultConfigLoader', () => { }); it('loads runtime config', async () => { - Object.defineProperty(process.env, 'APP_CONFIG', { - configurable: true, - value: [ - { data: { my: 'override-config' }, context: 'a' }, - { data: { my: 'config' }, context: 'b' }, - ] as AppConfig[], - }); + anyEnv.APP_CONFIG = [ + { data: { my: 'override-config' }, context: 'a' }, + { data: { my: 'config' }, context: 'b' }, + ]; + const configs = await (defaultConfigLoader as any)( '{"my":"runtime-config"}', ); @@ -62,20 +60,14 @@ describe('defaultConfigLoader', () => { }); it('fails to load invalid static config', async () => { - Object.defineProperty(process.env, 'APP_CONFIG', { - configurable: true, - value: { my: 'invalid-config' } as any, - }); + anyEnv.APP_CONFIG = { my: 'invalid-config' }; await expect(defaultConfigLoader()).rejects.toThrow( 'Static configuration has invalid format', ); }); it('fails to load bad runtime config', async () => { - Object.defineProperty(process.env, 'APP_CONFIG', { - configurable: true, - value: [{ data: { my: 'config' }, context: 'a' }] as AppConfig[], - }); + anyEnv.APP_CONFIG = [{ data: { my: 'config' }, context: 'a' }]; await expect((defaultConfigLoader as any)('}')).rejects.toThrow( 'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0', diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index 9d2096cfff..7fe77423e2 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -17,7 +17,6 @@ import React, { FC } from 'react'; import privateExports, { AppOptions, - ApiRegistry, defaultSystemIcons, BootErrorPageProps, AppConfigLoader, @@ -26,6 +25,7 @@ import { BrowserRouter, MemoryRouter } from 'react-router-dom'; import { ErrorPage } from '../layout/ErrorPage'; import { Progress } from '../components/Progress'; +import { defaultApis } from './defaultApis'; import { lightTheme, darkTheme } from '@backstage/theme'; import { AppConfig, JsonObject } from '@backstage/config'; @@ -94,7 +94,7 @@ export function createApp(options?: AppOptions) { ); }; - const apis = options?.apis ?? ApiRegistry.from([]); + const apis = options?.apis ?? []; const icons = { ...defaultSystemIcons, ...options?.icons }; const plugins = options?.plugins ?? []; const components = { @@ -127,6 +127,7 @@ export function createApp(options?: AppOptions) { components, themes, configLoader, + defaultApis, }); app.verify(); diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts new file mode 100644 index 0000000000..a3f0cb0251 --- /dev/null +++ b/packages/core/src/api-wrappers/defaultApis.ts @@ -0,0 +1,135 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + alertApiRef, + errorApiRef, + AlertApiForwarder, + ErrorApiForwarder, + ErrorAlerter, + featureFlagsApiRef, + FeatureFlags, + discoveryApiRef, + GoogleAuth, + GithubAuth, + OAuth2, + OktaAuth, + GitlabAuth, + Auth0Auth, + MicrosoftAuth, + oauthRequestApiRef, + OAuthRequestManager, + googleAuthApiRef, + githubAuthApiRef, + oauth2ApiRef, + oktaAuthApiRef, + gitlabAuthApiRef, + auth0AuthApiRef, + microsoftAuthApiRef, + storageApiRef, + WebStorage, + createApiFactory, + configApiRef, + UrlPatternDiscovery, +} from '@backstage/core-api'; + +export const defaultApis = [ + createApiFactory({ + api: discoveryApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + UrlPatternDiscovery.compile( + `${configApi.getString('backend.baseUrl')}/api/{{ pluginId }}`, + ), + }), + createApiFactory(alertApiRef, new AlertApiForwarder()), + createApiFactory({ + api: errorApiRef, + deps: { alertApi: alertApiRef }, + factory: ({ alertApi }) => + new ErrorAlerter(alertApi, new ErrorApiForwarder()), + }), + createApiFactory({ + api: storageApiRef, + deps: { errorApi: errorApiRef }, + factory: ({ errorApi }) => WebStorage.create({ errorApi }), + }), + createApiFactory(featureFlagsApiRef, new FeatureFlags()), + createApiFactory(oauthRequestApiRef, new OAuthRequestManager()), + createApiFactory({ + api: googleAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi }) => + GoogleAuth.create({ discoveryApi, oauthRequestApi }), + }), + createApiFactory({ + api: microsoftAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi }) => + MicrosoftAuth.create({ discoveryApi, oauthRequestApi }), + }), + createApiFactory({ + api: githubAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi }) => + GithubAuth.create({ discoveryApi, oauthRequestApi }), + }), + createApiFactory({ + api: oktaAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi }) => + OktaAuth.create({ discoveryApi, oauthRequestApi }), + }), + createApiFactory({ + api: gitlabAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi }) => + GitlabAuth.create({ discoveryApi, oauthRequestApi }), + }), + createApiFactory({ + api: auth0AuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi }) => + Auth0Auth.create({ discoveryApi, oauthRequestApi }), + }), + createApiFactory({ + api: oauth2ApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi }) => + OAuth2.create({ discoveryApi, oauthRequestApi }), + }), +]; diff --git a/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx b/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx index c0f57d6330..77f3ca51cc 100644 --- a/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx +++ b/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx @@ -86,3 +86,9 @@ export const Languages = () => ( ); + +export const CopyCode = () => ( + + + +); diff --git a/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx b/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx index 42efbf14af..1a76f84f44 100644 --- a/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx +++ b/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; import { CodeSnippet } from './CodeSnippet'; @@ -55,4 +55,14 @@ describe('', () => { expect(queryByText(/2/)).toBeInTheDocument(); expect(queryByText(/3/)).toBeInTheDocument(); }); + + it('copy code using button', async () => { + document.execCommand = jest.fn(); + const rendered = render( + wrapInTestApp(), + ); + const button = rendered.getByTitle('Text copied to clipboard'); + fireEvent.click(button); + expect(document.execCommand).toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/components/CodeSnippet/CodeSnippet.tsx b/packages/core/src/components/CodeSnippet/CodeSnippet.tsx index 324d71e936..b6d9b3a5aa 100644 --- a/packages/core/src/components/CodeSnippet/CodeSnippet.tsx +++ b/packages/core/src/components/CodeSnippet/CodeSnippet.tsx @@ -20,19 +20,22 @@ import SyntaxHighlighter from 'react-syntax-highlighter'; import { docco, dark } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; import { useTheme } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; +import { CopyTextButton } from '../CopyTextButton'; type Props = { text: string; language: string; showLineNumbers?: boolean; + showCopyCodeButton?: boolean; }; const defaultProps = { showLineNumbers: false, + showCopyCodeButton: false, }; export const CodeSnippet: FC = props => { - const { text, language, showLineNumbers } = { + const { text, language, showLineNumbers, showCopyCodeButton } = { ...defaultProps, ...props, }; @@ -41,13 +44,20 @@ export const CodeSnippet: FC = props => { const mode = theme.palette.type === 'dark' ? dark : docco; return ( - - {text} - +

); }; @@ -56,4 +66,5 @@ CodeSnippet.propTypes = { text: PropTypes.string.isRequired, language: PropTypes.string.isRequired, showLineNumbers: PropTypes.bool, + showCopyCodeButton: PropTypes.bool, }; diff --git a/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx b/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx index e789035b44..cab992e11a 100644 --- a/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx +++ b/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; import { CopyTextButton } from './CopyTextButton'; import { @@ -76,7 +76,7 @@ describe('', () => { ), ); const button = rendered.getByTitle('mockTooltip'); - button.click(); + fireEvent.click(button); expect(document.execCommand).toHaveBeenCalled(); rendered.getByText('mockTooltip'); }); diff --git a/packages/core/src/components/CopyTextButton/CopyTextButton.tsx b/packages/core/src/components/CopyTextButton/CopyTextButton.tsx index e4773165cc..cb322e5b56 100644 --- a/packages/core/src/components/CopyTextButton/CopyTextButton.tsx +++ b/packages/core/src/components/CopyTextButton/CopyTextButton.tsx @@ -63,7 +63,7 @@ export const CopyTextButton: FC = props => { }; const classes = useStyles(props); const errorApi = useApi(errorApiRef); - const inputRef = useRef(null); + const inputRef = useRef(null); const [open, setOpen] = useState(false); const handleCopyClick: MouseEventHandler = e => { @@ -82,9 +82,8 @@ export const CopyTextButton: FC = props => { return ( <> - diff --git a/packages/core/src/components/ProgressBars/ProgressCard.stories.tsx b/packages/core/src/components/ProgressBars/GaugeCard.stories.tsx similarity index 69% rename from packages/core/src/components/ProgressBars/ProgressCard.stories.tsx rename to packages/core/src/components/ProgressBars/GaugeCard.stories.tsx index 62c9f3be6e..922bb63b2f 100644 --- a/packages/core/src/components/ProgressBars/ProgressCard.stories.tsx +++ b/packages/core/src/components/ProgressBars/GaugeCard.stories.tsx @@ -15,26 +15,26 @@ */ import React from 'react'; -import { ProgressCard } from './ProgressCard'; +import { GaugeCard } from './GaugeCard'; import { Grid } from '@material-ui/core'; const linkInfo = { title: 'Go to XYZ Location', link: '#' }; export default { title: 'Progress Card', - component: ProgressCard, + component: GaugeCard, }; export const Default = () => ( - + - + - + ); @@ -42,21 +42,17 @@ export const Default = () => ( export const Subhead = () => ( - + - - ( export const LinkInFooter = () => ( - + - + - + ); diff --git a/packages/core/src/components/ProgressBars/ProgressCard.test.jsx b/packages/core/src/components/ProgressBars/GaugeCard.test.jsx similarity index 75% rename from packages/core/src/components/ProgressBars/ProgressCard.test.jsx rename to packages/core/src/components/ProgressBars/GaugeCard.test.jsx index 8568077a92..27ef0bb188 100644 --- a/packages/core/src/components/ProgressBars/ProgressCard.test.jsx +++ b/packages/core/src/components/ProgressBars/GaugeCard.test.jsx @@ -18,32 +18,30 @@ import React from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; -import { ProgressCard } from './ProgressCard'; +import { GaugeCard } from './GaugeCard'; const minProps = { title: 'Tingle upgrade', progress: 0.12 }; -describe('', () => { +describe('', () => { it('renders without exploding', () => { - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText(/Tingle.*/)).toBeInTheDocument(); }); it('renders progress and title', () => { - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText(/Tingle.*/)).toBeInTheDocument(); expect(getByText(/12%.*/)).toBeInTheDocument(); }); it('does not render deepLink', () => { - const { queryByText } = render( - wrapInTestApp(), - ); + const { queryByText } = render(wrapInTestApp()); expect(queryByText('View more')).not.toBeInTheDocument(); }); it('handles invalid numbers', () => { const badProps = { title: 'Tingle upgrade', progress: 'hejjo' }; - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText(/N\/A.*/)).toBeInTheDocument(); }); }); diff --git a/packages/core/src/components/ProgressBars/ProgressCard.tsx b/packages/core/src/components/ProgressBars/GaugeCard.tsx similarity index 90% rename from packages/core/src/components/ProgressBars/ProgressCard.tsx rename to packages/core/src/components/ProgressBars/GaugeCard.tsx index eef6312600..fc7055f705 100644 --- a/packages/core/src/components/ProgressBars/ProgressCard.tsx +++ b/packages/core/src/components/ProgressBars/GaugeCard.tsx @@ -18,7 +18,7 @@ import React, { FC } from 'react'; import { makeStyles } from '@material-ui/core'; import { InfoCard } from '../../layout/InfoCard'; import { BottomLinkProps } from '../../layout/BottomLink'; -import { CircleProgress } from './CircleProgress'; +import { GaugeProgress } from './GaugeProgress'; type Props = { title: string; @@ -36,7 +36,7 @@ const useStyles = makeStyles({ }, }); -export const ProgressCard: FC = props => { +export const GaugeCard: FC = props => { const classes = useStyles(props); const { title, subheader, progress, deepLink, variant } = props; @@ -48,7 +48,7 @@ export const ProgressCard: FC = props => { deepLink={deepLink} variant={variant} > - +
); diff --git a/packages/core/src/components/ProgressBars/CircleProgress.test.jsx b/packages/core/src/components/ProgressBars/GaugeProgress.test.jsx similarity index 82% rename from packages/core/src/components/ProgressBars/CircleProgress.test.jsx rename to packages/core/src/components/ProgressBars/GaugeProgress.test.jsx index 9b9eae1bb8..778abdf12c 100644 --- a/packages/core/src/components/ProgressBars/CircleProgress.test.jsx +++ b/packages/core/src/components/ProgressBars/GaugeProgress.test.jsx @@ -17,32 +17,32 @@ import React from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; -import { CircleProgress, getProgressColor } from './CircleProgress'; +import { GaugeProgress, getProgressColor } from './GaugeProgress'; -describe('', () => { +describe('', () => { it('renders without exploding', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('10%'); }); it('handles fractional prop', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('10%'); }); it('handles max prop', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('1%'); }); it('handles unit prop', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('10m'); }); diff --git a/packages/core/src/components/ProgressBars/CircleProgress.tsx b/packages/core/src/components/ProgressBars/GaugeProgress.tsx similarity index 98% rename from packages/core/src/components/ProgressBars/CircleProgress.tsx rename to packages/core/src/components/ProgressBars/GaugeProgress.tsx index 79a451b14e..14776ed431 100644 --- a/packages/core/src/components/ProgressBars/CircleProgress.tsx +++ b/packages/core/src/components/ProgressBars/GaugeProgress.tsx @@ -77,7 +77,7 @@ export function getProgressColor( return palette.status.ok; } -export const CircleProgress: FC = props => { +export const GaugeProgress: FC = props => { const classes = useStyles(props); const theme = useTheme(); const { value, fractional, inverse, unit, max } = { diff --git a/packages/core/src/components/ProgressBars/HorizontalProgress.stories.tsx b/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx similarity index 79% rename from packages/core/src/components/ProgressBars/HorizontalProgress.stories.tsx rename to packages/core/src/components/ProgressBars/LinearGauge.stories.tsx index 6e8f4ed7fd..c4492986b6 100644 --- a/packages/core/src/components/ProgressBars/HorizontalProgress.stories.tsx +++ b/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx @@ -15,29 +15,29 @@ */ import React from 'react'; -import { HorizontalProgress } from './HorizontalProgress'; +import { LinearGauge } from './LinearGauge'; const containerStyle = { width: 300 }; export default { - title: 'HorizontalProgress', - component: HorizontalProgress, + title: 'LinearGauge', + component: LinearGauge, }; export const Default = () => (
- +
); export const MediumProgress = () => (
- +
); export const LowProgress = () => (
- +
); diff --git a/packages/core/src/components/ProgressBars/HorizontalProgress.tsx b/packages/core/src/components/ProgressBars/LinearGauge.tsx similarity index 92% rename from packages/core/src/components/ProgressBars/HorizontalProgress.tsx rename to packages/core/src/components/ProgressBars/LinearGauge.tsx index 72bf1f34c3..73163b345f 100644 --- a/packages/core/src/components/ProgressBars/HorizontalProgress.tsx +++ b/packages/core/src/components/ProgressBars/LinearGauge.tsx @@ -19,7 +19,7 @@ import { Tooltip, useTheme } from '@material-ui/core'; // @ts-ignore import { Line } from 'rc-progress'; import { BackstageTheme } from '@backstage/theme'; -import { getProgressColor } from './CircleProgress'; +import { getProgressColor } from './GaugeProgress'; type Props = { /** @@ -28,7 +28,7 @@ type Props = { value: number; }; -export const HorizontalProgress: FC = ({ value }) => { +export const LinearGauge: FC = ({ value }) => { const theme = useTheme(); if (isNaN(value)) { return null; diff --git a/packages/core/src/components/ProgressBars/index.ts b/packages/core/src/components/ProgressBars/index.ts index c74e283ae6..c7131c8831 100644 --- a/packages/core/src/components/ProgressBars/index.ts +++ b/packages/core/src/components/ProgressBars/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export { ProgressCard } from './ProgressCard'; -export { CircleProgress } from './CircleProgress'; -export { HorizontalProgress } from './HorizontalProgress'; +export { GaugeCard } from './GaugeCard'; +export { GaugeProgress } from './GaugeProgress'; +export { LinearGauge } from './LinearGauge'; diff --git a/packages/core/src/components/SimpleStepper/SimpleStepper.stories.tsx b/packages/core/src/components/SimpleStepper/SimpleStepper.stories.tsx index 81f558f49d..52710ffeaf 100644 --- a/packages/core/src/components/SimpleStepper/SimpleStepper.stories.tsx +++ b/packages/core/src/components/SimpleStepper/SimpleStepper.stories.tsx @@ -51,7 +51,7 @@ export const ConditionalButtons = () => { setRequired(!!e.target.value)} + onChange={e => setRequired(!!e.target.value)} /> diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index d200b07bc6..882272cb43 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -127,7 +127,8 @@ function convertColumns( ): TableColumn[] { return columns.map(column => { const headerStyle: React.CSSProperties = {}; - const cellStyle: React.CSSProperties = {}; + const cellStyle: React.CSSProperties = + typeof column.cellStyle === 'object' ? column.cellStyle : {}; if (column.highlight) { headerStyle.color = theme.palette.textContrast; diff --git a/packages/core/src/components/Tabs/Tabs.tsx b/packages/core/src/components/Tabs/Tabs.tsx index 54369de473..336ed2b93b 100644 --- a/packages/core/src/components/Tabs/Tabs.tsx +++ b/packages/core/src/components/Tabs/Tabs.tsx @@ -48,7 +48,7 @@ export interface TabsProps { tabs: TabProps[]; } -const useStyles = makeStyles((theme: BackstageTheme) => ({ +const useStyles = makeStyles(theme => ({ root: { flexGrow: 1, width: '100%', @@ -66,7 +66,7 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({ export const Tabs: FC = ({ tabs }) => { const classes = useStyles(); - const [value, setValue] = useState([0, 0]); // [selectedChunckedNavIndex, selectedIndex] + const [value, setValue] = useState([0, 0]); // [selectedChunkedNavIndex, selectedIndex] const [navIndex, setNavIndex] = useState(0); const [numberOfChunkedElement, setNumberOfChunkedElement] = useState(0); const [chunkedTabs, setChunkedTabs] = useState([[]]); @@ -89,7 +89,7 @@ export const Tabs: FC = ({ tabs }) => { const hasNextNavIndex = () => navIndex + 1 < chunkedTabs.length; useEffect(() => { - // Each time the window is resized we calculate how many tabs wwe can render given the window width + // Each time the window is resized we calculate how many tabs we can render given the window width const padding = 20; // The AppBar padding const numberOfTabIcons = navIndex === 0 ? 1 : 2; @@ -99,7 +99,7 @@ export const Tabs: FC = ({ tabs }) => { const newChunkedElementSize = Math.floor(wrapperWidth / 170); setNumberOfChunkedElement(newChunkedElementSize); - setChunkedTabs(chunkArray([...tabs], newChunkedElementSize)); + setChunkedTabs(chunkArray(tabs, newChunkedElementSize)); setValue([ Math.floor(flattenIndex / newChunkedElementSize), flattenIndex % newChunkedElementSize, diff --git a/packages/core/src/components/Tabs/utils.ts b/packages/core/src/components/Tabs/utils.ts index 3e0ab6f2c3..8d6a3be5f3 100644 --- a/packages/core/src/components/Tabs/utils.ts +++ b/packages/core/src/components/Tabs/utils.ts @@ -13,15 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TabProps } from './Tabs'; -export const chunkArray = ( - myArray: TabProps[], - chunkSize: number, -): TabProps[][] => { - const results = []; - while (myArray.length) { - results.push(myArray.splice(0, chunkSize)); +export function chunkArray(array: T[], chunkSize: number): T[][] { + if (chunkSize <= 0) { + return [array]; } - return results; -}; + + const result: T[][] = []; + for (let i = 0; i < array.length; i += chunkSize) { + result.push(array.slice(i, i + chunkSize)); + } + + return result; +} diff --git a/packages/core/src/components/WarningPanel/WarningPanel.tsx b/packages/core/src/components/WarningPanel/WarningPanel.tsx index ab0927fc4c..ae4d2bff02 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import React, { FC } from 'react'; -import { Typography, makeStyles } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; +import { makeStyles, Typography } from '@material-ui/core'; import ErrorOutline from '@material-ui/icons/ErrorOutline'; +import React from 'react'; const useErrorOutlineStyles = makeStyles(theme => ({ root: { @@ -60,9 +60,10 @@ const useStyles = makeStyles(theme => ({ type Props = { message?: React.ReactNode; title?: string; + children?: React.ReactNode; }; -export const WarningPanel: FC = props => { +export const WarningPanel = (props: Props) => { const classes = useStyles(props); const { title, message, children } = props; return ( diff --git a/packages/core/src/components/stories/Chip.stories.tsx b/packages/core/src/components/stories/Chip.stories.tsx new file mode 100644 index 0000000000..c0e644fcc5 --- /dev/null +++ b/packages/core/src/components/stories/Chip.stories.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Chip } from '@material-ui/core'; + +export default { + title: 'Chip', + component: Chip, +}; + +export const Default = () => ; + +export const LargeDeletable = () => ( + ({})} /> +); + +export const LargeNotDeletable = () => ( + +); + +export const SmallDeletable = () => ( + ({})} /> +); + +export const SmallNotDeletable = () => ( + +); diff --git a/packages/core/src/layout/Header/Header.tsx b/packages/core/src/layout/Header/Header.tsx index cc2c8d1182..6b9ea05245 100644 --- a/packages/core/src/layout/Header/Header.tsx +++ b/packages/core/src/layout/Header/Header.tsx @@ -14,59 +14,63 @@ * limitations under the License. */ -import React, { Fragment, ReactNode, CSSProperties, FC } from 'react'; +import React, { ReactNode, CSSProperties, FC, useContext } from 'react'; import { Helmet } from 'react-helmet'; import { Typography, Tooltip, makeStyles } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; -import { Theme } from '../Page/Page'; -import { Waves } from './Waves'; +import { PageThemeContext } from '../Page/Page'; -const useStyles = makeStyles(theme => ({ - header: { - gridArea: 'pageHeader', - padding: theme.spacing(3), - minHeight: 118, - width: '100%', - boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', - position: 'relative', - zIndex: 100, - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'flex-end', - alignItems: 'center', - }, - leftItemsBox: { - flex: '1 1 auto', - }, - rightItemsBox: { - flex: '0 1 auto', - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - marginRight: theme.spacing(1), - }, - title: { - color: theme.palette.bursts.fontColor, - lineHeight: '1.0em', - wordBreak: 'break-all', - fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))', - marginBottom: theme.spacing(1), - }, - subtitle: { - color: 'rgba(255, 255, 255, 0.8)', - lineHeight: '1.0em', - }, - type: { - textTransform: 'uppercase', - fontSize: 11, - opacity: 0.8, - marginBottom: theme.spacing(1), - color: theme.palette.bursts.fontColor, - }, -})); +const useStyles = makeStyles( + theme => ({ + header: { + gridArea: 'pageHeader', + padding: theme.spacing(3), + minHeight: 118, + width: '100%', + boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', + position: 'relative', + zIndex: 100, + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-end', + alignItems: 'center', + backgroundImage: props => props.backgroundImage, + backgroundPosition: 'center', + backgroundSize: 'cover', + }, + leftItemsBox: { + flex: '1 1 auto', + }, + rightItemsBox: { + flex: '0 1 auto', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + marginRight: theme.spacing(1), + }, + title: { + color: theme.palette.bursts.fontColor, + lineHeight: '1.0em', + wordBreak: 'break-all', + fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))', + marginBottom: theme.spacing(1), + }, + subtitle: { + color: 'rgba(255, 255, 255, 0.8)', + lineHeight: '1.0em', + }, + type: { + textTransform: 'uppercase', + fontSize: 11, + opacity: 0.8, + marginBottom: theme.spacing(1), + color: theme.palette.bursts.fontColor, + }, + }), +); type HeaderStyles = ReturnType; @@ -159,32 +163,28 @@ export const Header: FC = ({ type, typeLink, }) => { - const classes = useStyles(); + const theme = useContext(PageThemeContext); + const classes = useStyles({ backgroundImage: theme.backgroundImage }); const documentTitle = pageTitleOverride || title; const pageTitle = title || pageTitleOverride; const titleTemplate = `${documentTitle} | %s | Backstage`; const defaultTitle = `${documentTitle} | Backstage`; return ( - + <> - - {theme => ( -
- -
- - - -
-
{children}
-
- )} -
-
+
+
+ + + +
+
{children}
+
+ ); }; diff --git a/packages/core/src/layout/Header/Waves.tsx b/packages/core/src/layout/Header/Waves.tsx deleted file mode 100644 index e87fc78105..0000000000 --- a/packages/core/src/layout/Header/Waves.tsx +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { FC } from 'react'; -import { makeStyles } from '@material-ui/core'; -import { PageTheme } from '../Page'; - -const useStyles = makeStyles({ - wave: { - position: 'absolute', - height: '100%', - width: '100%', - top: 0, - bottom: 0, - left: 0, - right: 0, - zIndex: -1, - }, -}); - -type Props = { - theme: PageTheme; -}; - -export const Waves: FC = ({ theme }) => { - const classes = useStyles(); - const [backgroundColor1, backgroundColor2] = theme.gradient.colors; - const waveColor = theme.gradient.waveColor; - const [opacityStart, opacityStop] = theme.gradient.opacity; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx b/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx new file mode 100644 index 0000000000..a007bf9113 --- /dev/null +++ b/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { HeaderTabs } from './'; + +const mockTabs = [ + { id: 'overview', label: 'Overview' }, + { id: 'docs', label: 'Docs' }, +]; + +describe('', () => { + it('should render tabs', () => { + const rendered = render(wrapInTestApp()); + + expect(rendered.getByText('Overview')).toBeInTheDocument(); + expect(rendered.getByText('Docs')).toBeInTheDocument(); + }); + + it('should render correct selected tab', () => { + const rendered = render(wrapInTestApp()); + + expect(rendered.getByText('Docs').parentElement).toHaveAttribute( + 'aria-selected', + 'false', + ); + + rendered.getByText('Docs').click(); + + expect(rendered.getByText('Docs').parentElement).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); +}); diff --git a/packages/core/src/layout/HeaderTabs/index.tsx b/packages/core/src/layout/HeaderTabs/index.tsx index 838b126569..b509cae14f 100644 --- a/packages/core/src/layout/HeaderTabs/index.tsx +++ b/packages/core/src/layout/HeaderTabs/index.tsx @@ -17,7 +17,7 @@ // TODO(blam): Remove this implementation when the Tabs are ready // This is just a temporary solution to implementing tabs for now -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { makeStyles, Tabs, Tab } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ @@ -46,15 +46,24 @@ export type Tab = { export const HeaderTabs: React.FC<{ tabs: Tab[]; onChange?: (index: number) => void; -}> = ({ tabs, onChange }) => { - const [selectedTab, setSelectedTab] = useState(0); + selectedIndex?: number; +}> = ({ tabs, onChange, selectedIndex }) => { + const [selectedTab, setSelectedTab] = useState(selectedIndex ?? 0); const styles = useStyles(); const handleChange = (_: React.ChangeEvent<{}>, index: number) => { - setSelectedTab(index); + if (selectedIndex === undefined) { + setSelectedTab(index); + } if (onChange) onChange(index); }; + useEffect(() => { + if (selectedIndex !== undefined) { + setSelectedTab(selectedIndex); + } + }, [selectedIndex]); + return (
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, + quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse + cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat + non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + +); + const Wrapper: FC<{}> = ({ children }) => ( - {children} + + {children} + ); export const Default = () => ( - -
- + {text} ); export const Subhead = () => ( - -
+ + {text} ); @@ -50,7 +60,7 @@ export const Subhead = () => ( export const LinkInFooter = () => ( -
+ {text} ); diff --git a/packages/core/src/layout/InfoCard/InfoCard.tsx b/packages/core/src/layout/InfoCard/InfoCard.tsx index 86e9c79ced..6914d396b1 100644 --- a/packages/core/src/layout/InfoCard/InfoCard.tsx +++ b/packages/core/src/layout/InfoCard/InfoCard.tsx @@ -14,12 +14,13 @@ * limitations under the License. */ -import React, { FC, ReactNode } from 'react'; +import React, { ReactNode } from 'react'; import { Card, CardActions, CardContent, CardHeader, + CardHeaderProps, Divider, withStyles, makeStyles, @@ -29,22 +30,26 @@ import { ErrorBoundary } from '../ErrorBoundary'; import { BottomLink, BottomLinkProps } from '../BottomLink'; const useStyles = makeStyles(theme => ({ - header: { - padding: theme.spacing(2, 2, 2, 2.5), - }, noPadding: { padding: 0, '&:last-child': { paddingBottom: 0, }, }, + header: { + padding: theme.spacing(2, 2, 2, 2.5), + }, + headerTitle: { + fontWeight: 700, + }, + headerSubheader: { + paddingTop: theme.spacing(1), + }, + headerAvatar: {}, + headerAction: {}, + headerContent: {}, })); -const BoldHeader = withStyles(theme => ({ - title: { fontWeight: 700 }, - subheader: { paddingTop: theme.spacing(1) }, -}))(CardHeader); - const CardActionsTopRight = withStyles(theme => ({ root: { display: 'inline-block', @@ -130,7 +135,7 @@ type Props = { cardStyle?: object; children?: ReactNode; headerStyle?: object; - headerProps?: object; + headerProps?: CardHeaderProps; actionsClassName?: string; actions?: ReactNode; cardClassName?: string; @@ -139,7 +144,7 @@ type Props = { noPadding?: boolean; }; -export const InfoCard: FC = ({ +export const InfoCard = ({ title, subheader, divider, @@ -155,7 +160,7 @@ export const InfoCard: FC = ({ actionsTopRight, className, noPadding, -}) => { +}: Props): JSX.Element => { const classes = useStyles(); /** @@ -186,8 +191,15 @@ export const InfoCard: FC = ({ {title && ( <> - ({ })); type ItemCardProps = { - description: string; + description?: string; tags?: string[]; title: string; type?: string; diff --git a/packages/core/src/layout/Page/Page.stories.tsx b/packages/core/src/layout/Page/Page.stories.tsx new file mode 100644 index 0000000000..8fd779196d --- /dev/null +++ b/packages/core/src/layout/Page/Page.stories.tsx @@ -0,0 +1,235 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { + Header, + Page, + HeaderLabel, + ContentHeader, + Content, + pageTheme, + InfoCard, + HeaderTabs, +} from '../'; +import { + SupportButton, + Table, + StatusOK, + TableColumn, + GaugeCard, + TrendLine, +} from '../../components'; +import { Box, Typography, Link, Chip, Grid } from '@material-ui/core'; + +export default { + title: 'Example Plugin', + component: Page, +}; + +interface TableData { + id: number; + branch: string; + hash: string; + status: string; +} + +const generateTestData = (rows = 10) => { + const data: Array = []; + while (data.length <= rows) { + data.push({ + id: data.length + 18534, + branch: 'techdocs: modify documentation header', + hash: 'techdocs/docs-header 5749c98e3f61f8bb116e5cb87b0e4e1 ', + status: 'Success', + }); + } + return data; +}; + +const columns: TableColumn[] = [ + { + title: 'ID', + field: 'id', + highlight: true, + type: 'numeric', + width: '80px', + }, + { + title: 'Message/Source', + highlight: true, + render: (row: Partial) => ( + <> + {row.branch} + {row.hash} + + ), + }, + { + title: 'Status', + render: (row: Partial) => ( + + + {row.status} + + ), + }, + { + title: 'Tags', + render: () => , + width: '10%', + }, +]; + +const tabs = [ + { label: 'Overview' }, + { label: 'CI/CD' }, + { label: 'Cost Efficiency' }, + { label: 'Code Coverage' }, + { label: 'Test' }, + { label: 'Compliance Advisor' }, +]; + +const DataGrid = () => ( + + + + + + + + + + + + + + + + + + + Rightsize GKE deployment + + Services are considered underutilized in GKE when the average usage of + requested cores is less than 80%. + + What can I do? + + Review requested core and limit settings. Check HPA target scaling + settings in hpa.yaml. The recommended value for  + targetCPUUtilizationPercentage is 80. + + + For single pods, there is of course no HPA. But it can also be useful + to think about a single pod out of a larger deployment, then modify + based on HPA requirements. Within a pod, each container has its own + CPU and memory requests and limits. + + Definitions + + A request is a minimum reserved value; a container will never have + less than this amount allocated to it, even if it doesn't actually use + it. Requests are used for determining what nodes to schedule pods on + (bin-packing). The tension here is between not allocating resources we + don't need, and having easy-enough access to enough resources to be + able to function. + + + Contact #cost-awareness for information and support. + + + + +); + +const ExampleHeader = () => ( +
+ + +
+); + +const ExampleContentHeader = ({ selectedTab }: { selectedTab?: number }) => ( + + + This Plugin is an example. This text could provide usefull information for + the user. + + +); + +export const PluginWithData = () => { + const [selectedTab, setSelectedTab] = useState(2); + return ( +
+ + + setSelectedTab(index)} + tabs={tabs.map(({ label }, index) => ({ + id: index.toString(), + label, + }))} + /> + + + + + +
+ ); +}; + +export const PluginWithTable = () => { + return ( +
+ + + + + + + + + ); +}; diff --git a/packages/core/src/layout/Page/Page.tsx b/packages/core/src/layout/Page/Page.tsx index bc4175fb6c..a5f5e4fd2b 100644 --- a/packages/core/src/layout/Page/Page.tsx +++ b/packages/core/src/layout/Page/Page.tsx @@ -18,7 +18,7 @@ import React, { FC } from 'react'; import { PageTheme, pageTheme } from './PageThemeProvider'; import { makeStyles } from '@material-ui/core'; -export const Theme = React.createContext(pageTheme.home); +export const PageThemeContext = React.createContext(pageTheme.home); const useStyles = makeStyles(() => ({ root: { @@ -38,8 +38,8 @@ type Props = { export const Page: FC = ({ theme = pageTheme.home, children }) => { const classes = useStyles(); return ( - +
{children}
-
+ ); }; diff --git a/packages/core/src/layout/Page/PageThemeProvider.ts b/packages/core/src/layout/Page/PageThemeProvider.ts index c99509513f..57411c8786 100644 --- a/packages/core/src/layout/Page/PageThemeProvider.ts +++ b/packages/core/src/layout/Page/PageThemeProvider.ts @@ -14,87 +14,59 @@ * limitations under the License. */ -export type Gradient = { - colors: string[]; - waveColor: string; - opacity: string[]; -}; - export type PageTheme = { - gradient: Gradient; + colors: string[]; + shape: string; + backgroundImage: string; }; -export const gradients: Record = { - darkGrey: { - colors: ['#171717', '#383838'], - waveColor: '#757575', - opacity: ['1.0', '0.0'], - }, - marineBlue: { - colors: ['#00759A', '#004EAC'], - waveColor: '#BDDBFF', - opacity: ['0.72', '0.0'], - }, - veryBlue: { - colors: ['#0B2B9C', '#311288'], - waveColor: '#8960FD', - opacity: ['0.72', '0.0'], - }, - rubyRed: { - colors: ['#A4284B', '#8D1134'], - waveColor: '#FFBFF5', - opacity: ['0.28', '0.10'], - }, - toastyOrange: { - colors: ['#CC3707', '#9A2500'], - waveColor: '#FF784E', - opacity: ['0.72', '0.0'], - }, - purpleSky: { - colors: ['#AF29F8', '#4100F4'], - waveColor: '#AF29F8', - opacity: ['0.72', '0.0'], - }, - eveningSea: { - colors: ['#00FFF2', '#035355'], - waveColor: '', - opacity: ['0.72', '0.0'], - }, - teal: { - colors: ['#005E4D', '#004E40'], - waveColor: '#9BF0E1', - opacity: ['0.72', '0.0'], - }, - pinkSea: { - colors: ['#C8077A', '#C2297D'], - waveColor: '#ea93c3', - opacity: ['0.8', '0.0'], - }, +/* + # How to add a shape + 1. Get the svg shape from figma, should be ~1400 wide, ~400 high + and only the white->transparent mask, no colors. + 2. Run it through https://jakearchibald.github.io/svgomg/ + 3. Run that through https://github.com/tigt/mini-svg-data-uri + with something like https://npm.runkit.com/mini-svg-data-uri + 4. Wrap the output in `url("")` + 5. Give it a name and paste it into the `shapes` object below. + +*/ +export const shapes: Record = { + wave: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='1368' height='400' fill='none'%3e%3cmask id='a' width='1368' height='401' x='0' y='0' maskUnits='userSpaceOnUse'%3e%3cpath fill='url(%23paint0_linear)' d='M437 116C223 116 112 0 112 0h1256v400c-82 0-225-21-282-109-112-175-436-175-649-175z'/%3e%3cpath fill='url(%23paint1_linear)' d='M1368 400V282C891-29 788 40 711 161 608 324 121 372 0 361v39h1368z'/%3e%3cpath fill='url(%23paint2_linear)' d='M1368 244v156H0V94c92-24 198-46 375 0l135 41c176 51 195 109 858 109z'/%3e%3cpath fill='url(%23paint3_linear)' d='M1252 400h116c-14-7-35-14-116-16-663-14-837-128-1013-258l-85-61C98 28 46 8 0 0v400h1252z'/%3e%3c/mask%3e%3cg mask='url(%23a)'%3e%3cpath fill='white' d='M-172-98h1671v601H-172z'/%3e%3c/g%3e%3cdefs%3e%3clinearGradient id='paint0_linear' x1='602' x2='1093.5' y1='-960.5' y2='272' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint1_linear' x1='482' x2='480' y1='1058.5' y2='70.5' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint2_linear' x1='424' x2='446.1' y1='-587.5' y2='274.6' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint3_linear' x1='587' x2='349' y1='-1120.5' y2='341' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3c/defs%3e%3c/svg%3e")`, + wave2: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='1368' height='400' fill='none'%3e%3cmask id='a' width='1764' height='479' x='-229' y='-6' maskUnits='userSpaceOnUse'%3e%3cpath fill='url(%23paint0_linear)' d='M0 400h1350C1321 336 525 33 179-2c-345-34-395 236-408 402H0z'/%3e%3cpath fill='url(%23paint1_linear)' d='M1378 177v223H0V217s219 75 327 52C436 246 717-35 965 45s254 144 413 132z'/%3e%3cpath fill='url(%23paint2_linear)' d='M26 400l-78-16c-170 205-44-6-137-30l-4-1 4 1 137 30c37-45 89-110 159-201 399-514-45 238 1176-50 275-65 354-39 91 267H26z'/%3e%3c/mask%3e%3cg mask='url(%23a)'%3e%3cpath fill='white' d='M0 0h1368v400H0z'/%3e%3c/g%3e%3cdefs%3e%3clinearGradient id='paint0_linear' x1='431' x2='397.3' y1='-599' y2='372.8' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint1_linear' x1='236.5' x2='446.6' y1='-586' y2='381.5' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint2_linear' x1='851.8' x2='640.4' y1='-867.2' y2='363.7' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3c/defs%3e%3c/svg%3e")`, + round: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='1368' height='400' fill='none'%3e%3cmask id='a' width='2269' height='1408' x='-610' y='-509' maskUnits='userSpaceOnUse'%3e%3ccircle cx='1212.8' cy='74.8' r='317.5' fill='url(%23paint0_linear)' transform='rotate(-52 1213 75)'/%3e%3ccircle cx='737.8' cy='445.8' r='317.5' fill='url(%23paint1_linear)' transform='rotate(-116 738 446)'/%3e%3ccircle cx='601.8' cy='52.8' r='418.6' fill='url(%23paint2_linear)' transform='rotate(-117 602 53)'/%3e%3ccircle cx='999.8' cy='364' r='389.1' fill='url(%23paint3_linear)' transform='rotate(31 1000 364)'/%3e%3cellipse cx='-109.2' cy='263.5' fill='url(%23paint4_linear)' rx='429.2' ry='465.8' transform='rotate(-85 -109 264)'/%3e%3c/mask%3e%3cg mask='url(%23a)'%3e%3cpath fill='white' d='M0 0h1368v400H0z'/%3e%3c/g%3e%3cdefs%3e%3clinearGradient id='paint0_linear' x1='1301.2' x2='161.4' y1='-1879.7' y2='-969.6' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint1_linear' x1='826.2' x2='-313.6' y1='-1508.7' y2='-598.6' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint2_linear' x1='718.4' x2='-784.3' y1='-2524' y2='-1324.2' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint3_linear' x1='1108.2' x2='-288.6' y1='-2031.1' y2='-915.9' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint4_linear' x1='10.4' x2='-1626.5' y1='-2603.8' y2='-1399.5' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3c/defs%3e%3c/svg%3e")`, +}; + +export const colorVariants: Record = { + darkGrey: ['#171717', '#383838'], + marineBlue: ['#006D8F', '#0049A1'], + veryBlue: ['#0027AF', '#270094'], + rubyRed: ['#98002B', '#8D1134'], + toastyOrange: ['#BE2200', '#A41D00'], + purpleSky: ['#8912CA', '#3E00EA'], + eveningSea: ['#00FFF2', '#035355'], + teal: ['#005B4B'], + pinkSea: ['#C8077A', '#C2297D'], }; export const pageTheme: Record = { - home: { - gradient: gradients.teal, - }, - documentation: { - gradient: gradients.pinkSea, - }, - tool: { - gradient: gradients.purpleSky, - }, - service: { - gradient: gradients.marineBlue, - }, - website: { - gradient: gradients.veryBlue, - }, - library: { - gradient: gradients.rubyRed, - }, - other: { - gradient: gradients.darkGrey, - }, - app: { - gradient: gradients.toastyOrange, - }, + home: genTheme(colorVariants.teal, shapes.wave), + documentation: genTheme(colorVariants.pinkSea, shapes.wave2), + tool: genTheme(colorVariants.purpleSky, shapes.round), + service: genTheme(colorVariants.marineBlue, shapes.wave), + website: genTheme(colorVariants.veryBlue, shapes.wave), + library: genTheme(colorVariants.rubyRed, shapes.wave), + other: genTheme(colorVariants.darkGrey, shapes.wave), + app: genTheme(colorVariants.toastyOrange, shapes.wave), }; + +// As the background shapes and colors are decorative, we place them onto +// the page as a css background-image instead of an html element of its own. +// Utility to not have to write colors and shapes twice. +function genTheme(colors: string[], shape: string) { + const gradientColors = colors.length === 1 ? [colors[0], colors[0]] : colors; + const gradient = `linear-gradient(90deg, ${gradientColors.join(', ')})`; + const backgroundImage = `${shape}, ${gradient}`; + + return { colors, shape, backgroundImage }; +} diff --git a/packages/core/src/layout/Page/index.ts b/packages/core/src/layout/Page/index.ts index f8fa7dc668..000a565c83 100644 --- a/packages/core/src/layout/Page/index.ts +++ b/packages/core/src/layout/Page/index.ts @@ -15,5 +15,5 @@ */ export { Page } from './Page'; -export { gradients, pageTheme } from './PageThemeProvider'; +export { pageTheme } from './PageThemeProvider'; export type { PageTheme } from './PageThemeProvider'; diff --git a/packages/core/src/layout/Sidebar/DefaultProviderSettings.tsx b/packages/core/src/layout/Sidebar/DefaultProviderSettings.tsx new file mode 100644 index 0000000000..293cefb19a --- /dev/null +++ b/packages/core/src/layout/Sidebar/DefaultProviderSettings.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + configApiRef, + githubAuthApiRef, + gitlabAuthApiRef, + googleAuthApiRef, + oauth2ApiRef, + oktaAuthApiRef, + microsoftAuthApiRef, + useApi, +} from '@backstage/core-api'; +import Star from '@material-ui/icons/Star'; +import React from 'react'; +import { OAuthProviderSettings, OIDCProviderSettings } from './Settings'; + +export const DefaultProviderSettings = () => { + const configApi = useApi(configApiRef); + const providersConfig = configApi.getOptionalConfig('auth.providers'); + const providers = providersConfig?.keys() ?? []; + + return ( + <> + {providers.includes('google') && ( + + )} + {providers.includes('microsoft') && ( + + )} + {providers.includes('github') && ( + + )} + {providers.includes('gitlab') && ( + + )} + {providers.includes('okta') && ( + + )} + {providers.includes('oauth2') && ( + + )} + + ); +}; diff --git a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx index 906317fe9c..d7451965fe 100644 --- a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx +++ b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx @@ -23,10 +23,13 @@ import { SidebarSearchField, SidebarSpace, SidebarUserSettings, + OAuthProviderSettings, } from '.'; import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined'; import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; +import Star from '@material-ui/icons/Star'; import { MemoryRouter } from 'react-router-dom'; +import { githubAuthApiRef } from '@backstage/core-api'; export default { title: 'Sidebar', @@ -55,6 +58,14 @@ export const SampleSidebar = () => ( - + + } + /> ); diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index e69d63186d..f586f6d077 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -14,35 +14,22 @@ * limitations under the License. */ -import { - githubAuthApiRef, - gitlabAuthApiRef, - googleAuthApiRef, - identityApiRef, - oauth2ApiRef, - oktaAuthApiRef, - useApi, - configApiRef, -} from '@backstage/core-api'; +import { identityApiRef, useApi } from '@backstage/core-api'; import Collapse from '@material-ui/core/Collapse'; import SignOutIcon from '@material-ui/icons/MeetingRoom'; -import Star from '@material-ui/icons/Star'; import React, { useContext, useEffect } from 'react'; import { SidebarContext } from './config'; import { SidebarItem } from './Items'; -import { - OAuthProviderSettings, - OIDCProviderSettings, - UserProfile as SidebarUserProfile, -} from './Settings'; +import { UserProfile as SidebarUserProfile } from './Settings'; -export function SidebarUserSettings() { +type SidebarUserSettingsProps = { providerSettings?: React.ReactNode }; + +export function SidebarUserSettings({ + providerSettings, +}: SidebarUserSettingsProps) { const { isOpen: sidebarOpen } = useContext(SidebarContext); const [open, setOpen] = React.useState(false); const identityApi = useApi(identityApiRef); - const configApi = useApi(configApiRef); - const providersConfig = configApi.getOptionalConfig('auth.providers'); - const providers = providersConfig?.keys() ?? []; // Close the provider list when sidebar collapse useEffect(() => { @@ -53,41 +40,8 @@ export function SidebarUserSettings() { <> - {providers.includes('google') && ( - - )} - {providers.includes('github') && ( - - )} - {providers.includes('gitlab') && ( - - )} - {providers.includes('okta') && ( - - )} - {providers.includes('oauth2') && ( - - )} + {providerSettings} + { + const auth0AuthApi = useApi(auth0AuthApiRef); + const errorApi = useApi(errorApiRef); + + const handleLogin = async () => { + try { + const identity = await auth0AuthApi.getBackstageIdentity({ + instantPopup: true, + }); + + const profile = await auth0AuthApi.getProfile(); + + onResult({ + userId: identity!.id, + profile: profile!, + getIdToken: () => + auth0AuthApi.getBackstageIdentity().then(i => i!.idToken), + logout: async () => { + await auth0AuthApi.logout(); + }, + }); + } catch (error) { + errorApi.post(error); + } + }; + + return ( + + + Sign In + + } + > + Sign In using Auth0 + + + ); +}; + +const loader: ProviderLoader = async apis => { + const auth0AuthApi = apis.get(auth0AuthApiRef)!; + + const identity = await auth0AuthApi.getBackstageIdentity({ + optional: true, + }); + + if (!identity) { + return undefined; + } + + const profile = await auth0AuthApi.getProfile(); + + return { + userId: identity.id, + profile: profile!, + getIdToken: () => auth0AuthApi.getBackstageIdentity().then(i => i!.idToken), + logout: async () => { + await auth0AuthApi.logout(); + }, + }; +}; + +export const auth0Provider: SignInProvider = { Component, loader }; diff --git a/packages/core/src/layout/SignInPage/customProvider.tsx b/packages/core/src/layout/SignInPage/customProvider.tsx index d3a292ce87..09ffcda10a 100644 --- a/packages/core/src/layout/SignInPage/customProvider.tsx +++ b/packages/core/src/layout/SignInPage/customProvider.tsx @@ -29,7 +29,8 @@ import { InfoCard } from '../InfoCard/InfoCard'; import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; import { GridItem } from './styles'; -const ID_TOKEN_REGEX = /^[a-z0-9+/]+\.[a-z0-9+/]+\.[a-z0-9+/]+$/i; +// accept base64url format according to RFC7515 (https://tools.ietf.org/html/rfc7515#section-3) +const ID_TOKEN_REGEX = /^[a-z0-9_\-]+\.[a-z0-9_\-]+\.[a-z0-9_\-]+$/i; const useFormStyles = makeStyles(theme => ({ form: { @@ -109,7 +110,7 @@ const Component: ProviderComponent = ({ onResult }) => { color="primary" variant="outlined" className={classes.button} - disabled={!formState?.dirty || !isEmpty(errors)} + disabled={!formState?.isDirty || !isEmpty(errors)} > Continue diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 6a1a3b60b5..af7b78eb74 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public" @@ -27,9 +27,9 @@ "start": "nodemon --" }, "dependencies": { - "@backstage/cli-common": "^0.1.1-alpha.18", + "@backstage/cli-common": "^0.1.1-alpha.21", "chalk": "^4.0.0", - "commander": "^4.1.1", + "commander": "^6.1.0", "fs-extra": "^9.0.0", "handlebars": "^4.7.3", "inquirer": "^7.0.4", @@ -38,7 +38,7 @@ }, "devDependencies": { "@types/fs-extra": "^9.0.1", - "@types/inquirer": "^6.5.0", + "@types/inquirer": "^7.3.1", "@types/ora": "^3.2.0", "@types/react-dev-utils": "^9.0.4", "@types/recursive-readdir": "^2.2.0", diff --git a/packages/create-app/templates/default-app/.gitignore b/packages/create-app/templates/default-app/.gitignore new file mode 100644 index 0000000000..4f9065c60b --- /dev/null +++ b/packages/create-app/templates/default-app/.gitignore @@ -0,0 +1,34 @@ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Coverage directory generated when running tests with coverage +coverage + +# Dependencies +node_modules/ + +# Node version directives +.nvmrc + +# dotenv environment variables file +.env +.env.test + +# Build output +dist +dist-types + +# Temporary change files created by Vim +*.swp + +# MkDocs build output +site + +# Local configuration files +*.local.yaml diff --git a/packages/create-app/templates/default-app/app-config.development.yaml b/packages/create-app/templates/default-app/app-config.development.yaml new file mode 100644 index 0000000000..da274ba1a8 --- /dev/null +++ b/packages/create-app/templates/default-app/app-config.development.yaml @@ -0,0 +1,11 @@ +app: + baseUrl: http://localhost:3000 + +backend: + baseUrl: http://localhost:7000 + listen: + port: 7000 + cors: + origin: http://localhost:3000 + methods: [GET, POST, PUT, DELETE] + credentials: true diff --git a/packages/create-app/templates/default-app/app-config.yaml b/packages/create-app/templates/default-app/app-config.yaml deleted file mode 100644 index addd74ab67..0000000000 --- a/packages/create-app/templates/default-app/app-config.yaml +++ /dev/null @@ -1,27 +0,0 @@ -app: - title: Scaffolded Backstage App - baseUrl: http://localhost:3000 - -organization: - name: Acme Corporation - -backend: - baseUrl: http://localhost:7000 - listen: - host: 0.0.0.0 - port: 7000 - cors: - origin: http://localhost:3000 - methods: [GET, POST, PUT, DELETE] - credentials: true - -proxy: - '/test': - target: 'https://example.com' - changeOrigin: true - -techdocs: - storageUrl: https://techdocs-mock-sites.storage.googleapis.com - -auth: - providers: {} diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs new file mode 100644 index 0000000000..ecac0613d9 --- /dev/null +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -0,0 +1,112 @@ +app: + title: Scaffolded Backstage App + baseUrl: http://localhost:7000 + +organization: + name: Acme Corporation + +backend: + baseUrl: http://localhost:7000 + listen: + port: 7000 + {{#if dbTypeSqlite}} + database: + client: sqlite3 + connection: ':memory:' + {{/if}} + {{#if dbTypePG}} + # config options: https://node-postgres.com/api/client + database: + client: pg + connection: + host: + $secret: + env: POSTGRES_HOST + port: + $secret: + env: POSTGRES_PORT + user: + $secret: + env: POSTGRES_USER + password: + $secret: + env: POSTGRES_PASSWORD + # https://node-postgres.com/features/ssl + #ssl: require # see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require) + #ca: # if you have a CA file and want to verify it you can uncomment this section + # $secret: + # file: /ca/server.crt + {{/if}} + +proxy: + '/test': + target: 'https://example.com' + changeOrigin: true + +techdocs: + storageUrl: http://localhost:7000/techdocs/static/docs + requestUrl: http://localhost:7000/techdocs/docs + +lighthouse: + baseUrl: http://localhost:3003 + +auth: + providers: {} + +scaffolder: + github: + token: + $secret: + env: GITHUB_ACCESS_TOKEN + visibility: public # or 'internal' or 'private' + +catalog: + rules: + - allow: [Component, API, Group, Template, Location] + processors: + github: + privateToken: + $secret: + env: GITHUB_PRIVATE_TOKEN + githubApi: + providers: + - target: https://github.com + token: + $secret: + env: GITHUB_PRIVATE_TOKEN + # Example for how to add your GitHub Enterprise instance: + # - target: https://ghe.example.net + # apiBaseUrl: https://ghe.example.net/api/v3 + # token: + # $secret: + # env: GHE_PRIVATE_TOKEN + locations: + # Backstage example components + - type: github + target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/all-components.yaml + + # Backstage example APIs + - type: github + target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml + + # Backstage example templates + - type: github + target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml + rules: + - allow: [Template] + - type: github + target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml + rules: + - allow: [Template] + - type: github + target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml + rules: + - allow: [Template] + - type: github + target: https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml + rules: + - allow: [Template] + - type: github + target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml + rules: + - allow: [Template] diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index b59ca63fa3..b95b5bbc5b 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -9,6 +9,7 @@ "start": "yarn workspace app start", "build": "lerna run build", "tsc": "tsc", + "tsc:full": "tsc --skipLibCheck false --incremental false", "clean": "backstage-cli clean && lerna run clean", "diff": "lerna run diff --", "test": "lerna run test --since origin/master -- --coverage", @@ -42,5 +43,10 @@ "*.{json,md}": [ "prettier --write" ] + }, + "jest": { + "transformModules": [ + "@kyma-project/asyncapi-react" + ] } } diff --git a/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js b/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js index 6c561a734c..dde26b7b39 100644 --- a/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js +++ b/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js @@ -1,8 +1,6 @@ describe('App', () => { - it('should render the welcome page', () => { + it('should render the catalog', () => { cy.visit('/'); - cy.contains('Welcome to Backstage'); - cy.contains('Getting Started'); - cy.contains('Quick Links'); + cy.contains('Backstage Service Catalog'); }); }); diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index c3de7ada80..a057826ba4 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -2,16 +2,26 @@ "name": "app", "version": "0.0.0", "private": true, + "bundled": true, "dependencies": { - "@material-ui/core": "^4.9.1", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", - "@material-ui/lab": "4.0.0-alpha.45", "@backstage/cli": "^{{version}}", "@backstage/core": "^{{version}}", + "@backstage/plugin-api-docs": "^{{version}}", + "@backstage/plugin-catalog": "^{{version}}", + "@backstage/plugin-register-component": "^{{version}}", + "@backstage/plugin-scaffolder": "^{{version}}", + "@backstage/plugin-techdocs": "^{{version}}", + "@backstage/catalog-model": "^{{version}}", + "@backstage/plugin-circleci": "^{{version}}", + "@backstage/plugin-explore": "^{{version}}", + "@backstage/plugin-lighthouse": "^{{version}}", + "@backstage/plugin-tech-radar": "^{{version}}", + "@backstage/plugin-github-actions": "^{{version}}", "@backstage/test-utils": "^{{version}}", "@backstage/theme": "^{{version}}", "history": "^5.0.0", - "plugin-welcome": "0.0.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", diff --git a/packages/create-app/templates/default-app/packages/app/public/android-chrome-192x192.png b/packages/create-app/templates/default-app/packages/app/public/android-chrome-192x192.png index 3d56edbb0d..4660f988c1 100644 Binary files a/packages/create-app/templates/default-app/packages/app/public/android-chrome-192x192.png and b/packages/create-app/templates/default-app/packages/app/public/android-chrome-192x192.png differ diff --git a/packages/create-app/templates/default-app/packages/app/public/apple-touch-icon.png b/packages/create-app/templates/default-app/packages/app/public/apple-touch-icon.png index 0977175f6f..57c05cfc9a 100644 Binary files a/packages/create-app/templates/default-app/packages/app/public/apple-touch-icon.png and b/packages/create-app/templates/default-app/packages/app/public/apple-touch-icon.png differ diff --git a/packages/create-app/templates/default-app/packages/app/public/favicon-16x16.png b/packages/create-app/templates/default-app/packages/app/public/favicon-16x16.png index a455ffac7b..58cf61a35e 100644 Binary files a/packages/create-app/templates/default-app/packages/app/public/favicon-16x16.png and b/packages/create-app/templates/default-app/packages/app/public/favicon-16x16.png differ diff --git a/packages/create-app/templates/default-app/packages/app/public/favicon-32x32.png b/packages/create-app/templates/default-app/packages/app/public/favicon-32x32.png index e2707f2d1e..c0915ece75 100644 Binary files a/packages/create-app/templates/default-app/packages/app/public/favicon-32x32.png and b/packages/create-app/templates/default-app/packages/app/public/favicon-32x32.png differ diff --git a/packages/create-app/templates/default-app/packages/app/public/favicon.ico b/packages/create-app/templates/default-app/packages/app/public/favicon.ico index 5b582704a1..5e45e5dfbd 100644 Binary files a/packages/create-app/templates/default-app/packages/app/public/favicon.ico and b/packages/create-app/templates/default-app/packages/app/public/favicon.ico differ diff --git a/packages/create-app/templates/default-app/packages/app/src/App.test.tsx b/packages/create-app/templates/default-app/packages/app/src/App.test.tsx index b94eab587f..e88c3b47dd 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.test.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.test.tsx @@ -10,6 +10,10 @@ describe('App', () => { { data: { app: { title: 'Test' }, + backend: { baseUrl: 'http://localhost:7000' }, + techdocs: { + storageUrl: 'http://localhost:7000/techdocs/static/docs', + }, }, context: 'test', }, diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 8a0aedca2e..9449ac68a1 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -1,19 +1,62 @@ -import { createApp } from '@backstage/core'; import React, { FC } from 'react'; +import { + createApp, + AlertDisplay, + OAuthRequestDialog, + SidebarPage, + createRouteRef, +} from '@backstage/core'; +import { apis } from './apis'; import * as plugins from './plugins'; +import { AppSidebar } from './sidebar'; +import { Route, Routes, Navigate } from 'react-router'; +import { Router as CatalogRouter } from '@backstage/plugin-catalog'; +import { Router as DocsRouter } from '@backstage/plugin-techdocs'; +import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component'; +import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; + +import { EntityPage } from './components/catalog/EntityPage'; const app = createApp({ + apis, plugins: Object.values(plugins), }); const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); -const AppRoutes = app.getRoutes(); +const deprecatedAppRoutes = app.getRoutes(); + +const catalogRouteRef = createRouteRef({ + path: '/catalog', + title: 'Service Catalog', +}); + const App: FC<{}> = () => ( + + - + + + + + } + /> + } /> + } + /> + } + /> + {deprecatedAppRoutes} + + ); diff --git a/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx b/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx new file mode 100644 index 0000000000..d2b1bf1080 --- /dev/null +++ b/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC } from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 30, + }, + path: { + fill: '#7df3e1', + }, +}); +const LogoFull: FC<{}> = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoFull; diff --git a/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx b/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx new file mode 100644 index 0000000000..d70be3dd32 --- /dev/null +++ b/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC } from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 28, + }, + path: { + fill: '#7df3e1', + }, +}); + +const LogoIcon: FC<{}> = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoIcon; diff --git a/packages/create-app/templates/default-app/packages/app/src/apis.ts b/packages/create-app/templates/default-app/packages/app/src/apis.ts new file mode 100644 index 0000000000..e0503e504f --- /dev/null +++ b/packages/create-app/templates/default-app/packages/app/src/apis.ts @@ -0,0 +1,17 @@ +import { + discoveryApiRef, + UrlPatternDiscovery, + createApiFactory, + configApiRef, +} from '@backstage/core'; + +export const apis = [ + createApiFactory({ + api: discoveryApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + UrlPatternDiscovery.compile( + `${configApi.getString('backend.baseUrl')}/{{ pluginId }}`, + ), + }), +]; diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx new file mode 100644 index 0000000000..5480d8218a --- /dev/null +++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx @@ -0,0 +1,133 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Router as GitHubActionsRouter, + isPluginApplicableToEntity as isGitHubActionsAvailable, +} from '@backstage/plugin-github-actions'; +import { + Router as CircleCIRouter, + isPluginApplicableToEntity as isCircleCIAvailable, +} from '@backstage/plugin-circleci'; +import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs'; +import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; + +import React from 'react'; +import { + EntityPageLayout, + useEntity, + AboutCard, +} from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; +import { Grid } from '@material-ui/core'; +import { WarningPanel } from '@backstage/core'; + +const CICDSwitcher = ({ entity }: { entity: Entity }) => { + // This component is just an example of how you can implement your company's logic in entity page. + // You can for example enforce that all components of type 'service' should use GitHubActions + switch (true) { + case isGitHubActionsAvailable(entity): + return ; + case isCircleCIAvailable(entity): + return ; + default: + return ( + + No CI/CD is available for this entity. Check corresponding + annotations! + + ); + } +}; + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + + + + + +); + +const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + } + /> + } + /> + +); + +const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + } + /> + +); + +const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + +); + +export const EntityPage = () => { + const { entity } = useEntity(); + switch (entity?.spec?.type) { + case 'service': + return ; + case 'website': + return ; + default: + return ; + } +}; diff --git a/packages/create-app/templates/default-app/packages/app/src/plugins.ts b/packages/create-app/templates/default-app/packages/app/src/plugins.ts index 000bd79f3e..c787ac2166 100644 --- a/packages/create-app/templates/default-app/packages/app/src/plugins.ts +++ b/packages/create-app/templates/default-app/packages/app/src/plugins.ts @@ -1 +1,9 @@ -export { plugin as WelcomePlugin } from 'plugin-welcome'; +export { plugin as CatalogPlugin } from '@backstage/plugin-catalog'; +export { plugin as RegisterComponent } from '@backstage/plugin-register-component'; +export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder'; +export { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs'; +export { plugin as Explore } from '@backstage/plugin-explore'; +export { plugin as Circleci } from '@backstage/plugin-circleci'; +export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; +export { plugin as TechRadar } from '@backstage/plugin-tech-radar'; +export { plugin as GithubActions } from '@backstage/plugin-github-actions'; diff --git a/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx b/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx new file mode 100644 index 0000000000..96d18965ec --- /dev/null +++ b/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx @@ -0,0 +1,79 @@ +import React, { FC, useContext } from 'react'; +import HomeIcon from '@material-ui/icons/Home'; +import LibraryBooks from '@material-ui/icons/LibraryBooks'; +import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import BuildIcon from '@material-ui/icons/BuildRounded'; +import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; +import MapIcon from '@material-ui/icons/MyLocation'; +import { Link, makeStyles } from '@material-ui/core'; +import { NavLink } from 'react-router-dom'; +import LogoFull from './LogoFull'; +import LogoIcon from './LogoIcon'; + +import { + Sidebar, + SidebarItem, + SidebarDivider, + sidebarConfig, + SidebarContext, + SidebarSpace, + SidebarUserSettings, + SidebarThemeToggle, + SidebarPinButton, + DefaultProviderSettings, +} from '@backstage/core'; + +export const AppSidebar = () => ( + + + + {/* Global nav, not org-specific */} + + + + + + + + {/* End global nav */} + + + + + } /> + + +); + +const useSidebarLogoStyles = makeStyles({ + root: { + width: sidebarConfig.drawerWidthClosed, + height: 3 * sidebarConfig.logoHeight, + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + marginBottom: -14, + }, + link: { + width: sidebarConfig.drawerWidthClosed, + marginLeft: 24, + }, +}); + +const SidebarLogo: FC<{}> = () => { + const classes = useSidebarLogoStyles(); + const { isOpen } = useContext(SidebarContext); + + return ( +
+ + {isOpen ? : } + +
+ ); +}; diff --git a/packages/create-app/templates/default-app/packages/backend/Dockerfile b/packages/create-app/templates/default-app/packages/backend/Dockerfile index 3e8ba36cec..a7bd814b19 100644 --- a/packages/create-app/templates/default-app/packages/backend/Dockerfile +++ b/packages/create-app/templates/default-app/packages/backend/Dockerfile @@ -2,8 +2,15 @@ FROM node:12 WORKDIR /usr/src/app -COPY . . +# Copy repo skeleton first, to avoid unnecessary docker cache invalidation. +# The skeleton contains the package.json of each package in the monorepo, +# and along with yarn.lock and the root package.json, that's enough to run yarn install. +ADD yarn.lock package.json skeleton.tar ./ RUN yarn install --frozen-lockfile --production +# This will copy the contents of the dist-workspace when running the build-image command. +# Do not use this Dockerfile outside of that command, as it will copy in the source code instead. +COPY . . + CMD ["node", "packages/backend"] diff --git a/packages/create-app/templates/default-app/packages/backend/README.md b/packages/create-app/templates/default-app/packages/backend/README.md index f94904a930..5583bff625 100644 --- a/packages/create-app/templates/default-app/packages/backend/README.md +++ b/packages/create-app/templates/default-app/packages/backend/README.md @@ -3,8 +3,8 @@ This package is an EXAMPLE of a Backstage backend. The main purpose of this package is to provide a test bed for Backstage plugins -that have a backend part. Feel free to experiment locally or within your fork -by adding dependencies and routes to this backend, to try things out. +that have a backend part. Feel free to experiment locally or within your fork by +adding dependencies and routes to this backend, to try things out. Our goal is to eventually amend the create-app flow of the CLI, such that a production ready version of a backend skeleton is made alongside the frontend @@ -33,34 +33,32 @@ LOG_LEVEL=debug \ yarn start ``` -Substitute `x` for actual values, or leave them as -dummy values just to try out the backend without using the auth or sentry features. +Substitute `x` for actual values, or leave them as dummy values just to try out +the backend without using the auth or sentry features. The backend starts up on port 7000 per default. ## Populating The Catalog -If you want to use the catalog functionality, you need to add so called locations -to the backend. These are places where the backend can find some entity descriptor -data to consume and serve. +If you want to use the catalog functionality, you need to add so called +locations to the backend. These are places where the backend can find some +entity descriptor data to consume and serve. For more information, see +[Software Catalog Overview - Adding Components to the Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview#adding-components-to-the-catalog). -To get started, you can issue the following after starting the backend, from inside -the `plugins/catalog-backend` directory: - -```bash -yarn mock-data -``` - -You should then start seeing data on `localhost:7000/catalog/entities`. - -The catalog currently runs in-memory only, so feel free to try it out, but it will -need to be re-populated on next startup. +To get started quickly, this template already includes some statically configured example locations +in `app-config.yaml` under `catalog.locations`. You can remove and replace these locations as you +like, and also override them for local development in `app-config.local.yaml`. ## Authentication -We chose [Passport](http://www.passportjs.org/) as authentication platform due to its comprehensive set of supported authentication [strategies](http://www.passportjs.org/packages/). +We chose [Passport](http://www.passportjs.org/) as authentication platform due +to its comprehensive set of supported authentication +[strategies](http://www.passportjs.org/packages/). -Read more about the [auth-backend](https://github.com/spotify/backstage/blob/master/plugins/auth-backend/README.md) and [how to add a new provider](https://github.com/spotify/backstage/blob/master/docs/auth/add-auth-provider.md) +Read more about the +[auth-backend](https://github.com/spotify/backstage/blob/master/plugins/auth-backend/README.md) +and +[how to add a new provider](https://github.com/spotify/backstage/blob/master/docs/auth/add-auth-provider.md) ## Documentation diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index a978349b02..adde81e830 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -9,7 +9,7 @@ }, "scripts": { "build": "backstage-cli backend:build", - "build-image": "backstage-cli backend:build-image example-backend", + "build-image": "backstage-cli backend:build-image --build --tag example-backend", "start": "backstage-cli backend:dev", "lint": "backstage-cli lint", "test": "backstage-cli test", @@ -26,9 +26,9 @@ "@backstage/plugin-proxy-backend": "^{{version}}", "@backstage/plugin-rollbar-backend": "^{{version}}", "@backstage/plugin-scaffolder-backend": "^{{version}}", - "@backstage/plugin-sentry-backend": "^{{version}}", "@backstage/plugin-techdocs-backend": "^{{version}}", "@octokit/rest": "^18.0.0", + "@gitbeaker/node": "^23.5.0", "dockerode": "^3.2.0", "express": "^4.17.1", "knex": "^0.21.1", diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts.hbs b/packages/create-app/templates/default-app/packages/backend/src/index.ts similarity index 69% rename from packages/create-app/templates/default-app/packages/backend/src/index.ts.hbs rename to packages/create-app/templates/default-app/packages/backend/src/index.ts index 98f40ba4a4..6a014727e9 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts.hbs +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -7,18 +7,13 @@ */ import { + createDatabaseClient, createServiceBuilder, loadBackendConfig, getRootLogger, useHotMemoize, } from '@backstage/backend-common'; import { ConfigReader, AppConfig } from '@backstage/config'; -{{#if dbTypePG}} -import knex, { PgConnectionConfig } from 'knex'; -{{/if}} -{{#if dbTypeSqlite}} -import knex from 'knex'; -{{/if}} import auth from './plugins/auth'; import catalog from './plugins/catalog'; import identity from './plugins/identity'; @@ -32,31 +27,14 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { return (plugin: string): PluginEnvironment => { const logger = getRootLogger().child({ type: 'plugin', plugin }); - - {{#if dbTypePG}} - const knexConfig = { - client: 'pg', - useNullAsDefault: true, - connection: { - port: process.env.POSTGRES_PORT, - host: process.env.POSTGRES_HOST, - user: process.env.POSTGRES_USER, - password: process.env.POSTGRES_PASSWORD, - database: `backstage_plugin_${plugin}`, - } as PgConnectionConfig, - }; - {{/if}} - {{#if dbTypeSqlite}} - const knexConfig = { - client: 'sqlite3', - connection: ':memory:', - useNullAsDefault: true, - }; - {{/if}} - const database = knex(knexConfig); - database.client.pool.on('createSuccess', (_eventId: any, resource: any) => { - resource.run('PRAGMA foreign_keys = ON', () => {}); - }); + const database = createDatabaseClient( + config.getConfig('backend.database'), + { + connection: { + database: `backstage_plugin_${plugin}`, + }, + }, + ); return { logger, database, config }; }; } @@ -80,7 +58,7 @@ async function main() { .addRouter('/auth', await auth(authEnv)) .addRouter('/identity', await identity(identityEnv)) .addRouter('/techdocs', await techdocs(techdocsEnv)) - .addRouter('/proxy', await proxy(proxyEnv)); + .addRouter('/proxy', await proxy(proxyEnv, '/proxy')); await service.start().catch(err => { console.log(err); diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts index 574d0c17a7..b20265c2cd 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts @@ -2,9 +2,9 @@ import { createRouter } from '@backstage/plugin-proxy-backend'; import { PluginEnvironment } from '../types'; -export default async function createPlugin({ - logger, - config, -}: PluginEnvironment) { - return await createRouter({ logger, config }); +export default async function createPlugin( + { logger, config }: PluginEnvironment, + pathPrefix: string, +) { + return await createRouter({ logger, config, pathPrefix }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index ffa10cc3c6..df5e0a79c1 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -3,16 +3,24 @@ import { createRouter, FilePreparer, GithubPreparer, + GitlabPreparer, Preparers, + Publishers, GithubPublisher, + GitlabPublisher, CreateReactAppTemplater, Templaters, + RepoVisilityOptions, } from '@backstage/plugin-scaffolder-backend'; import { Octokit } from '@octokit/rest'; +import { Gitlab } from '@gitbeaker/node'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; -export default async function createPlugin({ logger }: PluginEnvironment) { +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); @@ -21,19 +29,48 @@ export default async function createPlugin({ logger }: PluginEnvironment) { const filePreparer = new FilePreparer(); const githubPreparer = new GithubPreparer(); + const gitlabPreparer = new GitlabPreparer(config); const preparers = new Preparers(); preparers.register('file', filePreparer); preparers.register('github', githubPreparer); + preparers.register('gitlab', gitlabPreparer); + preparers.register('gitlab/api', gitlabPreparer); - const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN }); - const publisher = new GithubPublisher({ client: githubClient }); + const publishers = new Publishers(); + + const githubToken = config.getString('scaffolder.github.token'); + const repoVisibility = config.getString( + 'scaffolder.github.visibility', + ) as RepoVisilityOptions; + + const githubClient = new Octokit({ auth: githubToken }); + const githubPublisher = new GithubPublisher({ + client: githubClient, + token: githubToken, + repoVisibility, + }); + publishers.register('file', githubPublisher); + publishers.register('github', githubPublisher); + + const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab.api'); + + if (gitLabConfig) { + const gitLabToken = gitLabConfig.getString('token'); + const gitLabClient = new Gitlab({ + host: gitLabConfig.getOptionalString('baseUrl'), + token: gitLabToken, + }); + const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); + publishers.register('gitlab', gitLabPublisher); + publishers.register('gitlab/api', gitLabPublisher); + } const dockerClient = new Docker(); return await createRouter({ preparers, templaters, - publisher, + publishers, logger, dockerClient, }); diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts index 8c5144f50c..dfd9eb5e33 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts @@ -1,6 +1,7 @@ import { createRouter, DirectoryPreparer, + GithubPreparer, Preparers, Generators, LocalPublish, @@ -14,15 +15,18 @@ export default async function createPlugin({ config, }: PluginEnvironment) { const generators = new Generators(); - const techdocsGenerator = new TechdocsGenerator(); + const techdocsGenerator = new TechdocsGenerator(logger); + generators.register('techdocs', techdocsGenerator); - const directoryPreparer = new DirectoryPreparer(); const preparers = new Preparers(); + const directoryPreparer = new DirectoryPreparer(logger); + const githubPreparer = new GithubPreparer(logger); preparers.register('dir', directoryPreparer); + preparers.register('github', githubPreparer); - const publisher = new LocalPublish(); + const publisher = new LocalPublish(logger); const dockerClient = new Docker(); diff --git a/packages/create-app/templates/default-app/plugins/welcome/README.md b/packages/create-app/templates/default-app/plugins/welcome/README.md deleted file mode 100644 index e9b9dd0edf..0000000000 --- a/packages/create-app/templates/default-app/plugins/welcome/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Title - -Welcome to the welcome plugin! diff --git a/packages/create-app/templates/default-app/plugins/welcome/dev/index.tsx b/packages/create-app/templates/default-app/plugins/welcome/dev/index.tsx deleted file mode 100644 index 6fce113093..0000000000 --- a/packages/create-app/templates/default-app/plugins/welcome/dev/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; - -createDevApp().registerPlugin(plugin).render(); diff --git a/packages/create-app/templates/default-app/plugins/welcome/src/components/Timer/Timer.tsx b/packages/create-app/templates/default-app/plugins/welcome/src/components/Timer/Timer.tsx deleted file mode 100644 index 24c79f91ee..0000000000 --- a/packages/create-app/templates/default-app/plugins/welcome/src/components/Timer/Timer.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { FC } from 'react'; -import { HeaderLabel } from '@backstage/core'; - -const timeFormat = { hour: '2-digit', minute: '2-digit' }; -const utcOptions = { timeZone: 'UTC', ...timeFormat }; -const nycOptions = { timeZone: 'America/New_York', ...timeFormat }; -const tyoOptions = { timeZone: 'Asia/Tokyo', ...timeFormat }; -const stoOptions = { timeZone: 'Europe/Stockholm', ...timeFormat }; - -const defaultTimes = { - timeNY: '', - timeUTC: '', - timeTYO: '', - timeSTO: '', -}; - -function getTimes() { - const d = new Date(); - const lang = window.navigator.language; - - // Using the browser native toLocaleTimeString instead of huge moment-tz - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString - const timeNY = d.toLocaleTimeString(lang, nycOptions); - const timeUTC = d.toLocaleTimeString(lang, utcOptions); - const timeTYO = d.toLocaleTimeString(lang, tyoOptions); - const timeSTO = d.toLocaleTimeString(lang, stoOptions); - - return { timeNY, timeUTC, timeTYO, timeSTO }; -} - -const HomePageTimer: FC<{}> = () => { - const [{ timeNY, timeUTC, timeTYO, timeSTO }, setTimes] = React.useState( - defaultTimes, - ); - - React.useEffect(() => { - setTimes(getTimes()); - - const intervalId = setInterval(() => { - setTimes(getTimes()); - }, 1000); - - return () => { - clearInterval(intervalId); - }; - }, []); - - return ( - <> - - - - - - ); -}; - -export default HomePageTimer; diff --git a/packages/create-app/templates/default-app/plugins/welcome/src/components/Timer/index.ts b/packages/create-app/templates/default-app/plugins/welcome/src/components/Timer/index.ts deleted file mode 100644 index f1fc55bfe9..0000000000 --- a/packages/create-app/templates/default-app/plugins/welcome/src/components/Timer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Timer'; diff --git a/packages/create-app/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx b/packages/create-app/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx deleted file mode 100644 index 27e44a3f75..0000000000 --- a/packages/create-app/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import WelcomePage from './WelcomePage'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; - -describe('WelcomePage', () => { - it('should render', () => { - const rendered = render( - - - , - ); - expect(rendered.baseElement).toBeInTheDocument(); - }); -}); diff --git a/packages/create-app/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/packages/create-app/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx deleted file mode 100644 index 31c1c0f3e2..0000000000 --- a/packages/create-app/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React, { FC } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; -import { - Typography, - Grid, - List, - ListItem, - ListItemText, - Link, -} from '@material-ui/core'; -import Timer from '../Timer'; -import { - Content, - InfoCard, - Header, - Page, - pageTheme, - ContentHeader, - SupportButton, -} from '@backstage/core'; - -const WelcomePage: FC<{}> = () => { - const profile = { givenName: '' }; - - return ( - -
- -
- - - - - - - - - You now have a running instance of Backstage! - - 🎉 - - Let's make sure you get the most out of this platform by walking - you through the basics. - - - The Setup - - - Backstage is put together from three base concepts: the core, - the app and the plugins. - - - - - - - - - - - - - - Try It Out - - - We suggest you either check out the documentation for{' '} - - creating a plugin - {' '} - or have a look in the code for the{' '} - - Home Page - {' '} - in the directory "plugins/home-page/src". - - - - - - Quick Links - - - backstage.io - - - - Create a plugin - - - - - - - -
- ); -}; - -export default WelcomePage; diff --git a/packages/create-app/templates/default-app/plugins/welcome/src/components/WelcomePage/index.ts b/packages/create-app/templates/default-app/plugins/welcome/src/components/WelcomePage/index.ts deleted file mode 100644 index b031301e7e..0000000000 --- a/packages/create-app/templates/default-app/plugins/welcome/src/components/WelcomePage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './WelcomePage'; diff --git a/packages/create-app/templates/default-app/plugins/welcome/src/index.ts b/packages/create-app/templates/default-app/plugins/welcome/src/index.ts deleted file mode 100644 index 99edba26c3..0000000000 --- a/packages/create-app/templates/default-app/plugins/welcome/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { plugin } from './plugin'; diff --git a/packages/create-app/templates/default-app/plugins/welcome/src/plugin.test.ts b/packages/create-app/templates/default-app/plugins/welcome/src/plugin.test.ts deleted file mode 100644 index f5bf8e68c3..0000000000 --- a/packages/create-app/templates/default-app/plugins/welcome/src/plugin.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { plugin } from './plugin'; - -describe('welcome', () => { - it('should export plugin', () => { - expect(plugin).toBeDefined(); - }); -}); diff --git a/packages/create-app/templates/default-app/plugins/welcome/src/plugin.ts b/packages/create-app/templates/default-app/plugins/welcome/src/plugin.ts deleted file mode 100644 index a65fad5348..0000000000 --- a/packages/create-app/templates/default-app/plugins/welcome/src/plugin.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createPlugin } from '@backstage/core'; -import WelcomePage from './components/WelcomePage'; - -export const plugin = createPlugin({ - id: 'welcome', - register({ router }) { - router.registerRoute('/', WelcomePage); - }, -}); diff --git a/packages/create-app/templates/default-app/plugins/welcome/src/setupTests.ts b/packages/create-app/templates/default-app/plugins/welcome/src/setupTests.ts deleted file mode 100644 index 7b0828bfa8..0000000000 --- a/packages/create-app/templates/default-app/plugins/welcome/src/setupTests.ts +++ /dev/null @@ -1 +0,0 @@ -import '@testing-library/jest-dom'; diff --git a/packages/create-app/templates/default-app/tsconfig.json b/packages/create-app/templates/default-app/tsconfig.json index 9dbc3bbe27..ba3f90177d 100644 --- a/packages/create-app/templates/default-app/tsconfig.json +++ b/packages/create-app/templates/default-app/tsconfig.json @@ -8,7 +8,7 @@ ], "exclude": ["node_modules"], "compilerOptions": { - "outDir": "dist", - "skipLibCheck": true + "outDir": "dist-types", + "rootDir": "." } } diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 7eab350990..fe7272bf04 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/dev-utils", "description": "Utilities for developing Backstage plugins.", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public", @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/test-utils": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/dev-utils/src/devApp/apiFactories.test.ts b/packages/dev-utils/src/devApp/apiFactories.test.ts deleted file mode 100644 index be3ae2cdbe..0000000000 --- a/packages/dev-utils/src/devApp/apiFactories.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as apiFactories from './apiFactories'; -import { ApiTestRegistry, ApiFactory } from '@backstage/core'; - -describe('apiFactories', () => { - it('should be possible to get an instance of each API', () => { - const registry = new ApiTestRegistry(); - const factories: ApiFactory[] = Object.values( - apiFactories, - ); - - for (const factory of factories) { - registry.register(factory); - } - - for (const factory of factories) { - const api = registry.get(factory.implements); - expect(api).toBeDefined(); - } - }); -}); diff --git a/packages/dev-utils/src/devApp/apiFactories.ts b/packages/dev-utils/src/devApp/apiFactories.ts deleted file mode 100644 index 35295b4631..0000000000 --- a/packages/dev-utils/src/devApp/apiFactories.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - alertApiRef, - errorApiRef, - ErrorApiForwarder, - AlertApi, - createApiFactory, - ErrorAlerter, - AlertApiForwarder, - oauthRequestApiRef, - OAuthRequestManager, - GoogleAuth, - googleAuthApiRef, - GithubAuth, - githubAuthApiRef, - GitlabAuth, - gitlabAuthApiRef, -} from '@backstage/core'; - -// TODO(rugvip): We should likely figure out how to reuse all of these between apps -// and plugin serve with minimal boilerplate. For example we might move everything -// to DI, and provide factories for the default implementations, so this just becomes -// a list of things like `[ErrorApiForwarder.factory, AlertApiDialog.factory]`. - -export const alertApiFactory = createApiFactory({ - implements: alertApiRef, - deps: {}, - factory: (): AlertApi => new AlertApiForwarder(), -}); - -export const errorApiFactory = createApiFactory({ - implements: errorApiRef, - deps: { alertApi: alertApiRef }, - factory: ({ alertApi }) => - new ErrorAlerter(alertApi, new ErrorApiForwarder()), -}); - -export const oauthRequestApiFactory = createApiFactory({ - implements: oauthRequestApiRef, - deps: {}, - factory: () => new OAuthRequestManager(), -}); - -export const googleAuthApiFactory = createApiFactory({ - implements: googleAuthApiRef, - deps: { oauthRequestApi: oauthRequestApiRef }, - factory: ({ oauthRequestApi }) => - GoogleAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -}); - -export const githubAuthApiFactory = createApiFactory({ - implements: githubAuthApiRef, - deps: { oauthRequestApi: oauthRequestApiRef }, - factory: ({ oauthRequestApi }) => - GithubAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -}); - -export const gitlabAuthApiFactory = createApiFactory({ - implements: gitlabAuthApiRef, - deps: { oauthRequestApi: oauthRequestApiRef }, - factory: ({ oauthRequestApi }) => - GitlabAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -}); diff --git a/packages/dev-utils/src/devApp/render.test.tsx b/packages/dev-utils/src/devApp/render.test.tsx new file mode 100644 index 0000000000..fbebf2039b --- /dev/null +++ b/packages/dev-utils/src/devApp/render.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { useApi, configApiRef } from '@backstage/core'; +import { createDevApp } from './render'; + +const anyEnv = (process.env = { ...process.env }) as any; + +describe('DevAppBuilder', () => { + it('should be able to render a component in a dev app', async () => { + anyEnv.APP_CONFIG = [ + { context: 'test', data: { app: { title: 'Test App' } } }, + ]; + + const MyComponent = () => { + const configApi = useApi(configApiRef); + return
My App: {configApi.getString('app.title')}
; + }; + + const DevApp = createDevApp() + .addRootChild() + .build(); + + const rendered = render(); + + expect(await rendered.findByText('My App: Test App')).toBeInTheDocument(); + }); +}); diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 0e28a5bf8b..cca3470f3f 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -26,13 +26,12 @@ import { SidebarSpacer, ApiFactory, createPlugin, - ApiTestRegistry, - ApiHolder, AlertDisplay, OAuthRequestDialog, + AnyApiFactory, } from '@backstage/core'; -import * as defaultApiFactories from './apiFactories'; import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied'; +import { Routes } from 'react-router'; // TODO(rugvip): export proper plugin type from core that isn't the plugin class type BackstagePlugin = ReturnType; @@ -43,7 +42,7 @@ type BackstagePlugin = ReturnType; */ class DevAppBuilder { private readonly plugins = new Array(); - private readonly factories = new Array>(); + private readonly apis = new Array(); private readonly rootChildren = new Array(); /** @@ -57,10 +56,10 @@ class DevAppBuilder { /** * Register an API factory to add to the app */ - registerApiFactory( - factory: ApiFactory, + registerApi( + factory: ApiFactory, ): DevAppBuilder { - this.factories.push(factory); + this.apis.push(factory); return this; } @@ -79,13 +78,13 @@ class DevAppBuilder { */ build(): ComponentType<{}> { const app = createApp({ - apis: this.setupApiRegistry(this.factories), + apis: this.apis, plugins: this.plugins, }); const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); - const AppRoutes = app.getRoutes(); + const deprecatedAppRoutes = app.getRoutes(); const sidebar = this.setupSidebar(this.plugins); @@ -99,7 +98,7 @@ class DevAppBuilder { {sidebar} - + {deprecatedAppRoutes} @@ -170,37 +169,22 @@ class DevAppBuilder { ); } - // Set up an API registry that merges together default implementations with ones provided through config. - private setupApiRegistry( - providedFactories: ApiFactory[], - ): ApiHolder { - const providedApis = new Set( - providedFactories.map(factory => factory.implements), - ); - - // Exlude any default API factory that we receive a factory for in the config - const defaultFactories = Object.values(defaultApiFactories).filter( - factory => !providedApis.has(factory.implements), - ); - const allFactories = [...defaultFactories, ...providedFactories]; - - // Use a test registry with dependency injection so that the consumer - // can override APIs but still depend on the default implementations. - const registry = new ApiTestRegistry(); - for (const factory of allFactories) { - registry.register(factory); - } - - return registry; - } - private findPluginPaths(plugins: BackstagePlugin[]) { const paths = new Array(); for (const plugin of plugins) { for (const output of plugin.output()) { - if (output.type === 'legacy-route') { - paths.push(output.path); + switch (output.type) { + case 'legacy-route': { + paths.push(output.path); + break; + } + case 'route': { + paths.push(output.target.path); + break; + } + default: + break; } } } diff --git a/packages/docgen/package.json b/packages/docgen/package.json index 4b832afdfd..c75ea53faf 100644 --- a/packages/docgen/package.json +++ b/packages/docgen/package.json @@ -1,7 +1,7 @@ { "name": "docgen", "description": "Tool for generating API Documentation for itself", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": true, "homepage": "https://backstage.io", "repository": { @@ -27,7 +27,7 @@ }, "dependencies": { "chalk": "^4.0.0", - "commander": "^4.1.1", + "commander": "^6.1.0", "fs-extra": "^9.0.0", "github-slugger": "^1.3.0", "ts-node": "^8.6.2", diff --git a/packages/docgen/src/docgen/ApiDocGenerator.ts b/packages/docgen/src/docgen/ApiDocGenerator.ts index bfe449155b..3edcfedb9c 100644 --- a/packages/docgen/src/docgen/ApiDocGenerator.ts +++ b/packages/docgen/src/docgen/ApiDocGenerator.ts @@ -15,7 +15,11 @@ */ import ts from 'typescript'; -import { relative } from 'path'; +import { + relative as relativePath, + sep as pathSep, + posix as posixPath, +} from 'path'; import { ExportedInstance, ApiDoc, @@ -25,6 +29,12 @@ import { TypeLink, } from './types'; +// Always use unix path separators for the relative file +// paths, since we'll be using those for HTTP URLs. +function relativeLink(basePath: string, filePath: string) { + return relativePath(basePath, filePath).split(pathSep).join(posixPath.sep); +} + /** * The ApiDocGenerator uses the typescript compiler API to build the data structure that * describes a Backstage API and all of it's related types. @@ -58,7 +68,7 @@ export default class ApiDocGenerator { const id = this.getObjectPropertyLiteral(info, 'id'); const description = this.getObjectPropertyLiteral(info, 'description'); - const file = relative(this.basePath, source.fileName); + const file = relativeLink(this.basePath, source.fileName); const { line } = source.getLineAndCharacterOfPosition( apiInstance.node.getStart(), ); @@ -111,7 +121,7 @@ export default class ApiDocGenerator { const name = (type.aliasSymbol || type.symbol).name; const [declaration] = (type.aliasSymbol || type.symbol).declarations; const sourceFile = declaration.getSourceFile(); - const file = relative(this.basePath, sourceFile.fileName); + const file = relativeLink(this.basePath, sourceFile.fileName); const { line } = sourceFile.getLineAndCharacterOfPosition( declaration.getStart(), ); @@ -234,7 +244,7 @@ export default class ApiDocGenerator { const { line } = sourceFile.getLineAndCharacterOfPosition( declaration.getStart(), ); - const file = relative(this.basePath, sourceFile.fileName); + const file = relativeLink(this.basePath, sourceFile.fileName); const typeInfo = { id: (symbol as any).id, name: symbol.name, diff --git a/packages/e2e-test/.eslintrc.js b/packages/e2e-test/.eslintrc.js new file mode 100644 index 0000000000..ce2592e03b --- /dev/null +++ b/packages/e2e-test/.eslintrc.js @@ -0,0 +1,16 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], + ignorePatterns: ['templates/**'], + rules: { + 'no-console': 0, + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: true, + optionalDependencies: false, + peerDependencies: false, + bundledDependencies: false, + }, + ], + }, +}; diff --git a/packages/e2e-test/README.md b/packages/e2e-test/README.md new file mode 100644 index 0000000000..6cf50283c5 --- /dev/null +++ b/packages/e2e-test/README.md @@ -0,0 +1,24 @@ +# e2e-test + +End-to-end test for verifying Backstage packages. + +## Usage + +This package is only meant for usage within the Backstage monorepo. + +All packages need to be installed and built before running the test. In a fresh clone of this repo you first need to run the following from the repo root: + +```sh +yarn install +yarn tsc +yarn build +``` + +Once those tasks have completed, you can now run the test using `yarn start` inside this package. + +If you make changes to other packages you will need to rerun `yarn tsc && yarn build`. Changes to this package do not require a rebuild. + +## Documentation + +- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) diff --git a/packages/e2e-test/package.json b/packages/e2e-test/package.json new file mode 100644 index 0000000000..0af233ec8e --- /dev/null +++ b/packages/e2e-test/package.json @@ -0,0 +1,35 @@ +{ + "name": "e2e-test", + "description": "E2E test for verifying Backstage packages", + "version": "0.1.1-alpha.21", + "private": true, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/spotify/backstage", + "directory": "packages/e2e-test" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "src/index.js", + "scripts": { + "start": "node .", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "test:e2e": "yarn start" + }, + "devDependencies": { + "@backstage/cli-common": "^0.1.1-alpha.21", + "@types/fs-extra": "^9.0.1", + "@types/node": "^13.7.2", + "fs-extra": "^9.0.0", + "handlebars": "^4.7.3", + "node-fetch": "^2.6.0", + "pgtools": "^0.3.0", + "tree-kill": "^1.2.2", + "ts-node": "^8.6.2", + "zombie": "^6.1.4" + } +} diff --git a/packages/cli/e2e-test/cli-e2e-test.js b/packages/e2e-test/src/e2e-test.ts similarity index 70% rename from packages/cli/e2e-test/cli-e2e-test.js rename to packages/e2e-test/src/e2e-test.ts index 8f518762cd..83f92ea360 100644 --- a/packages/cli/e2e-test/cli-e2e-test.js +++ b/packages/e2e-test/src/e2e-test.ts @@ -14,13 +14,14 @@ * limitations under the License. */ -const os = require('os'); -const fs = require('fs-extra'); -const fetch = require('node-fetch'); -const killTree = require('tree-kill'); -const { resolve: resolvePath, join: joinPath } = require('path'); -const Browser = require('zombie'); -const { +import os from 'os'; +import fs from 'fs-extra'; +import fetch from 'node-fetch'; +import handlebars from 'handlebars'; +import killTree from 'tree-kill'; +import { resolve as resolvePath, join as joinPath } from 'path'; +import Browser from 'zombie'; +import { spawnPiped, runPlain, handleError, @@ -28,8 +29,17 @@ const { waitFor, waitForExit, print, -} = require('./helpers'); -const pgtools = require('pgtools'); +} from './helpers'; +import pgtools from 'pgtools'; +import { findPaths } from '@backstage/cli-common'; + +const paths = findPaths(__dirname); + +const templatePackagePaths = [ + 'packages/cli/templates/default-plugin/package.json.hbs', + 'packages/create-app/templates/default-app/packages/app/package.json.hbs', + 'packages/create-app/templates/default-app/packages/backend/package.json.hbs', +]; async function main() { const rootDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-')); @@ -53,28 +63,41 @@ async function main() { print('All tests successful, removing test dir'); await fs.remove(rootDir); + + // Just in case some child process was left hanging + process.exit(0); } /** * Builds a dist workspace that contains the cli and core packages */ -async function buildDistWorkspace(workspaceName, rootDir) { +async function buildDistWorkspace(workspaceName: string, rootDir: string) { const workspaceDir = resolvePath(rootDir, workspaceName); await fs.ensureDir(workspaceDir); + // We grab the needed dependencies from the create app template + const createAppDeps = new Set(); + for (const pkgJsonPath of templatePackagePaths) { + const path = paths.resolveOwnRoot(pkgJsonPath); + const pkgTemplate = await fs.readFile(path, 'utf8'); + const { dependencies = {}, devDependencies = {} } = JSON.parse( + handlebars.compile(pkgTemplate)({ version: '0.0.0' }), + ); + + Array() + .concat(Object.keys(dependencies), Object.keys(devDependencies)) + .filter(name => name.startsWith('@backstage/')) + .forEach(dep => createAppDeps.add(dep)); + } + print(`Preparing workspace`); await runPlain([ 'yarn', 'backstage-cli', 'build-workspace', workspaceDir, - '@backstage/cli', '@backstage/create-app', - '@backstage/core', - '@backstage/dev-utils', - '@backstage/test-utils', - // We don't use the backend itself, but want all of its dependencies - 'example-backend', + ...createAppDeps, ]); print('Pinning yarn version in workspace'); @@ -91,14 +114,19 @@ async function buildDistWorkspace(workspaceName, rootDir) { /** * Pin the yarn version in a directory to the one we're using in the Backstage repo */ -async function pinYarnVersion(dir) { - const repoRoot = resolvePath(__dirname, '../../..'); - - const yarnRc = await fs.readFile(resolvePath(repoRoot, '.yarnrc'), 'utf8'); +async function pinYarnVersion(dir: string) { + const yarnRc = await fs.readFile(paths.resolveOwnRoot('.yarnrc'), 'utf8'); const yarnRcLines = yarnRc.split('\n'); const yarnPathLine = yarnRcLines.find(line => line.startsWith('yarn-path')); - const [, localYarnPath] = yarnPathLine.match(/"(.*)"/); - const yarnPath = resolvePath(repoRoot, localYarnPath); + if (!yarnPathLine) { + throw new Error(`Unable to find 'yarn-path' in ${yarnRc}`); + } + const match = yarnPathLine.match(/"(.*)"/); + if (!match) { + throw new Error(`Invalid 'yarn-path' in ${yarnRc}`); + } + const [, localYarnPath] = match; + const yarnPath = paths.resolveOwnRoot(localYarnPath); await fs.writeFile(resolvePath(dir, '.yarnrc'), `yarn-path "${yarnPath}"\n`); } @@ -106,7 +134,12 @@ async function pinYarnVersion(dir) { /** * Creates a new app inside rootDir called test-app, using packages from the workspaceDir */ -async function createApp(appName, isPostgres, workspaceDir, rootDir) { +async function createApp( + appName: string, + isPostgres: boolean, + workspaceDir: string, + rootDir: string, +) { const child = spawnPiped( [ 'node', @@ -120,20 +153,20 @@ async function createApp(appName, isPostgres, workspaceDir, rootDir) { try { let stdout = ''; - child.stdout.on('data', data => { + child.stdout?.on('data', data => { stdout = stdout + data.toString('utf8'); }); await waitFor(() => stdout.includes('Enter a name for the app')); - child.stdin.write(`${appName}\n`); + child.stdin?.write(`${appName}\n`); await waitFor(() => stdout.includes('Select database for the backend')); if (!isPostgres) { // Simulate down arrow press - child.stdin.write(`\u001B\u005B\u0042`); + child.stdin?.write(`\u001B\u005B\u0042`); } - child.stdin.write(`\n`); + child.stdin?.write(`\n`); print('Waiting for app create script to be done'); await waitForExit(child); @@ -175,7 +208,7 @@ async function createApp(appName, isPostgres, workspaceDir, rootDir) { /** * This points dependency resolutions into the workspace for each package that is present there */ -async function overrideModuleResolutions(appDir, workspaceDir) { +async function overrideModuleResolutions(appDir: string, workspaceDir: string) { const pkgJsonPath = resolvePath(appDir, 'package.json'); const pkgJson = await fs.readJson(pkgJsonPath); @@ -201,19 +234,19 @@ async function overrideModuleResolutions(appDir, workspaceDir) { /** * Uses create-plugin command to create a new plugin in the app */ -async function createPlugin(pluginName, appDir) { +async function createPlugin(pluginName: string, appDir: string) { const child = spawnPiped(['yarn', 'create-plugin'], { cwd: appDir, }); try { let stdout = ''; - child.stdout.on('data', data => { + child.stdout?.on('data', (data: Buffer) => { stdout = stdout + data.toString('utf8'); }); await waitFor(() => stdout.includes('Enter an ID for the plugin')); - child.stdin.write(`${pluginName}\n`); + child.stdin?.write(`${pluginName}\n`); // await waitFor(() => stdout.includes('Enter the owner(s) of the plugin')); // child.stdin.write('@someuser\n'); @@ -236,27 +269,40 @@ async function createPlugin(pluginName, appDir) { /** * Start serving the newly created app and make sure that the create plugin is rendering correctly */ -async function testAppServe(pluginName, appDir) { +async function testAppServe(pluginName: string, appDir: string) { const startApp = spawnPiped(['yarn', 'start'], { cwd: appDir, + env: { + ...process.env, + GITHUB_ACCESS_TOKEN: 'abc', + }, }); Browser.localhost('localhost', 3000); let successful = false; try { - const browser = new Browser(); + for (let attempts = 1; ; attempts++) { + try { + const browser = new Browser(); - await waitForPageWithText(browser, '/', 'Welcome to Backstage'); - await waitForPageWithText( - browser, - `/${pluginName}`, - `Welcome to ${pluginName}!`, - ); + await waitForPageWithText(browser, '/', 'Backstage Service Catalog'); + await waitForPageWithText( + browser, + `/${pluginName}`, + `Welcome to ${pluginName}!`, + ); - print('Both App and Plugin loaded correctly'); - successful = true; - } catch (error) { - throw new Error(`App serve test failed, ${error}`); + print('Both App and Plugin loaded correctly'); + successful = true; + break; + } catch (error) { + if (attempts >= 5) { + throw new Error(`App serve test failed, ${error}`); + } + console.log(`App serve failed, trying again, ${error}`); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } } finally { // Kill entire process group, otherwise we'll end up with hanging serve processes killTree(startApp.pid); @@ -272,7 +318,7 @@ async function testAppServe(pluginName, appDir) { } /** Creates PG databases (drops if exists before) */ -async function createDB(database) { +async function createDB(database: string) { const config = { host: process.env.POSTGRES_HOST, port: process.env.POSTGRES_PORT, @@ -291,7 +337,7 @@ async function createDB(database) { /** * Start serving the newly created backend and make sure that all db migrations works correctly */ -async function testBackendStart(appDir, isPostgres) { +async function testBackendStart(appDir: string, isPostgres: boolean) { if (isPostgres) { print('Creating DBs'); await Promise.all( @@ -309,14 +355,18 @@ async function testBackendStart(appDir, isPostgres) { const child = spawnPiped(['yarn', 'workspace', 'backend', 'start'], { cwd: appDir, + env: { + ...process.env, + GITHUB_ACCESS_TOKEN: 'abc', + }, }); let stdout = ''; let stderr = ''; - child.stdout.on('data', data => { + child.stdout?.on('data', (data: Buffer) => { stdout = stdout + data.toString('utf8'); }); - child.stderr.on('data', data => { + child.stderr?.on('data', (data: Buffer) => { stderr = stderr + data.toString('utf8'); }); let successful = false; @@ -354,4 +404,4 @@ async function testBackendStart(appDir, isPostgres) { } process.on('unhandledRejection', handleError); -main(process.argv.slice(2)).catch(handleError); +main().catch(handleError); diff --git a/packages/e2e-test/src/helpers.test.ts b/packages/e2e-test/src/helpers.test.ts new file mode 100644 index 0000000000..196241f9e0 --- /dev/null +++ b/packages/e2e-test/src/helpers.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { waitFor } from './helpers'; + +describe('waitFor', () => { + it('should wait for true', async () => { + const fn = jest.fn().mockReturnValue(true); + await waitFor(fn); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should time out', async () => { + const fn = jest.fn().mockReturnValue(false); + await expect(waitFor(fn, 1)).rejects.toThrow( + 'Timed out while waiting for condition', + ); + expect(fn).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/e2e-test/helpers.js b/packages/e2e-test/src/helpers.ts similarity index 80% rename from packages/cli/e2e-test/helpers.js rename to packages/e2e-test/src/helpers.ts index 580f53cbe8..b59de75860 100644 --- a/packages/cli/e2e-test/helpers.js +++ b/packages/e2e-test/src/helpers.ts @@ -14,16 +14,21 @@ * limitations under the License. */ -const { spawn, execFile: execFileCb } = require('child_process'); -const { promisify } = require('util'); +import { + spawn, + execFile as execFileCb, + SpawnOptions, + ChildProcess, +} from 'child_process'; +import { promisify } from 'util'; const execFile = promisify(execFileCb); const EXPECTED_LOAD_ERRORS = /ECONNREFUSED|ECONNRESET|did not get to load all resources/; -function spawnPiped(cmd, options) { - function pipeWithPrefix(stream, prefix = '') { - return data => { +export function spawnPiped(cmd: string[], options?: SpawnOptions) { + function pipeWithPrefix(stream: NodeJS.WriteStream, prefix = '') { + return (data: Buffer) => { const prefixedMsg = data .toString('utf8') .trimRight() @@ -40,11 +45,11 @@ function spawnPiped(cmd, options) { child.on('error', handleError); const logPrefix = cmd.map(s => s.replace(/.+\//, '')).join(' '); - child.stdout.on( + child.stdout?.on( 'data', pipeWithPrefix(process.stdout, `[${logPrefix}].out: `), ); - child.stderr.on( + child.stderr?.on( 'data', pipeWithPrefix(process.stderr, `[${logPrefix}].err: `), ); @@ -52,7 +57,7 @@ function spawnPiped(cmd, options) { return child; } -async function runPlain(cmd, options) { +export async function runPlain(cmd: string[], options?: SpawnOptions) { try { const { stdout } = await execFile(cmd[0], cmd.slice(1), { ...options, @@ -70,8 +75,9 @@ async function runPlain(cmd, options) { } } -function handleError(err) { +export function handleError(err: Error & { code?: unknown }) { process.stdout.write(`${err.name}: ${err.stack || err.message}\n`); + if (typeof err.code === 'number') { process.exit(err.code); } else { @@ -85,9 +91,14 @@ function handleError(err) { * .cancel() is available * @returns {Promise} Promise of resolution */ -function waitFor(fn) { - return new Promise(resolve => { +export function waitFor(fn: () => boolean, maxSeconds: number = 120) { + let count = 0; + return new Promise((resolve, reject) => { const handle = setInterval(() => { + if (count++ > maxSeconds * 10) { + reject(new Error('Timed out while waiting for condition')); + return; + } if (fn()) { clearInterval(handle); resolve(); @@ -97,7 +108,7 @@ function waitFor(fn) { }); } -async function waitForExit(child) { +export async function waitForExit(child: ChildProcess) { if (child.exitCode !== null) { throw new Error(`Child already exited with code ${child.exitCode}`); } @@ -113,10 +124,10 @@ async function waitForExit(child) { ); } -async function waitForPageWithText( - browser, - path, - text, +export async function waitForPageWithText( + browser: any, + path: string, + text: string, { intervalMs = 1000, maxLoadAttempts = 240, maxFindTextAttempts = 3 } = {}, ) { let loadAttempts = 0; @@ -163,16 +174,6 @@ async function waitForPageWithText( } } -function print(msg) { +export function print(msg: string) { return process.stdout.write(`${msg}\n`); } - -module.exports = { - spawnPiped, - runPlain, - handleError, - waitFor, - waitForExit, - waitForPageWithText, - print, -}; diff --git a/packages/e2e-test/src/index.js b/packages/e2e-test/src/index.js new file mode 100644 index 0000000000..90f8030a0e --- /dev/null +++ b/packages/e2e-test/src/index.js @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +require('ts-node').register({ + transpileOnly: true, + project: require('path').resolve(__dirname, '../../../tsconfig.json'), + compilerOptions: { + module: 'CommonJS', + }, +}); + +require('./e2e-test'); diff --git a/packages/e2e-test/src/types.d.ts b/packages/e2e-test/src/types.d.ts new file mode 100644 index 0000000000..0ae86490ee --- /dev/null +++ b/packages/e2e-test/src/types.d.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module 'zombie'; +declare module 'pgtools'; diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 051d95de05..a0a220d3de 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -18,6 +18,8 @@ import { OAuthRequestManager, OktaAuth, oktaAuthApiRef, + Auth0Auth, + auth0AuthApiRef, configApiRef, ConfigReader, } from '@backstage/core'; @@ -78,6 +80,15 @@ builder.add( }), ); +builder.add( + auth0AuthApiRef, + Auth0Auth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +); + builder.add( oauth2ApiRef, OAuth2.create({ diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js index 2ee58a8c4f..c83bb221be 100644 --- a/packages/storybook/.storybook/main.js +++ b/packages/storybook/.storybook/main.js @@ -22,7 +22,7 @@ module.exports = { const [jsLoader] = config.module.rules.splice(0, 1); if (jsLoader.use[0].loader !== 'babel-loader') { throw new Error( - `Unexpected loader removed from storybook config, ${jsonLoader.use[0].loader}`, + `Unexpected loader removed from storybook config, ${jsLoader.use[0].loader}`, ); } diff --git a/packages/storybook/.storybook/config.js b/packages/storybook/.storybook/preview.js similarity index 68% rename from packages/storybook/.storybook/config.js rename to packages/storybook/.storybook/preview.js index 69145ea057..f92e07b045 100644 --- a/packages/storybook/.storybook/config.js +++ b/packages/storybook/.storybook/preview.js @@ -22,4 +22,24 @@ addParameters({ // Set the initial theme current: 'light', }, + layout: 'fullscreen', }); + +export const parameters = { + options: { + storySort: { + order: [ + 'Example Plugin', + 'Header', + 'Sidebar', + 'Tabs', + 'Information Card', + 'Tabbed Card', + 'Table', + 'Status', + 'Trendline', + 'Progress Card', + ], + }, + }, +}; diff --git a/packages/storybook/.storybook/webpack-plugin-fail-build-on-warning.js b/packages/storybook/.storybook/webpack-plugin-fail-build-on-warning.js index 1ccf825839..b812b6bac9 100644 --- a/packages/storybook/.storybook/webpack-plugin-fail-build-on-warning.js +++ b/packages/storybook/.storybook/webpack-plugin-fail-build-on-warning.js @@ -19,7 +19,7 @@ * https://github.com/spotify/backstage/issues/718. To make sure new warnings are not introduced with new PRs, we * want to fail CI builds if there are warnings when building storybook. * - * This webpack plugin makes sure the CI builds fail on Webpack warnings. We also have a whitelist of warnings here + * This webpack plugin makes sure the CI builds fail on Webpack warnings. We also have an allowlist of warnings here * which we think are non-critical. * * Note that this implementation will not detect other warnings emitted by storybook build that are separate from @@ -32,7 +32,7 @@ */ class WebpackPluginFailBuildOnWarning { // Ignore the following warnings in the Webpack build. - warningsWhitelist = new Set([ + warningsAllowlist = new Set([ 'AssetsOverSizeLimitWarning', 'EntrypointsOverSizeLimitWarning', 'NoAsyncChunksWarning', @@ -50,7 +50,7 @@ class WebpackPluginFailBuildOnWarning { if (warnings.length > 0) { // Throw error if there are unexpected warnings. for (let warning of warnings) { - if (!this.warningsWhitelist.has(warning.name)) { + if (!this.warningsAllowlist.has(warning.name)) { process.on('beforeExit', () => { console.log( `You have some unexpected warning(s) in your webpack build. Exiting process as error.`, diff --git a/packages/storybook/package.json b/packages/storybook/package.json index bbb8743522..8f12a2891c 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -1,6 +1,6 @@ { "name": "storybook", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "description": "Storybook build for core package", "private": true, "scripts": { @@ -14,14 +14,14 @@ ] }, "dependencies": { - "@backstage/theme": "^0.1.1-alpha.18" + "@backstage/theme": "^0.1.1-alpha.21" }, "devDependencies": { - "@storybook/addon-actions": "^5.3.17", - "@storybook/addon-links": "^5.3.17", - "@storybook/addon-storysource": "^5.3.18", - "@storybook/addons": "^5.3.17", - "@storybook/react": "^5.3.17", - "storybook-dark-mode": "^0.6.1" + "@storybook/addon-actions": "^6.0.21", + "@storybook/addon-links": "^6.0.21", + "@storybook/addon-storysource": "^6.0.21", + "@storybook/addons": "^6.0.21", + "@storybook/react": "^6.0.21", + "storybook-dark-mode": "^1.0.2" } } diff --git a/packages/techdocs-cli/bin/build.sh b/packages/techdocs-cli/bin/build.sh index 225cc334aa..0a3dfeb96d 100755 --- a/packages/techdocs-cli/bin/build.sh +++ b/packages/techdocs-cli/bin/build.sh @@ -16,9 +16,8 @@ set -e -ROOT_DIR=$(git rev-parse --show-toplevel) -TECHDOCS_PREVIEW_SOURCE=$ROOT_DIR/plugins/techdocs/dist -TECHDOCS_PREVIEW_DEST=$ROOT_DIR/packages/techdocs-cli/dist/techdocs-preview-bundle +TECHDOCS_PREVIEW_SOURCE=../../plugins/techdocs/dist +TECHDOCS_PREVIEW_DEST=../../packages/techdocs-cli/dist/techdocs-preview-bundle # Build the CLI yarn run backstage-cli -- build --outputs cjs diff --git a/packages/techdocs-cli/bin/techdocs-cli b/packages/techdocs-cli/bin/techdocs-cli index d21094a028..ccc9c23beb 100755 --- a/packages/techdocs-cli/bin/techdocs-cli +++ b/packages/techdocs-cli/bin/techdocs-cli @@ -21,7 +21,7 @@ const path = require('path'); const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); if (!isLocal) { - require('../dist'); + require('..'); } else { require('ts-node').register({ transpileOnly: true, diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json index 2e5d1e3958..222620f976 100644 --- a/packages/techdocs-cli/package.json +++ b/packages/techdocs-cli/package.json @@ -1,7 +1,7 @@ { "name": "@techdocs/cli", "description": "CLI for running TechDocs locally.", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public" @@ -44,8 +44,8 @@ "ext": "ts" }, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "commander": "^5.1.0", + "@backstage/cli": "^0.1.1-alpha.21", + "commander": "^6.1.0", "fs-extra": "^9.0.1", "http-proxy": "^1.18.1", "react-dev-utils": "^10.2.1", diff --git a/packages/techdocs-container/Dockerfile b/packages/techdocs-container/Dockerfile index d5eec712a5..e910935baf 100644 --- a/packages/techdocs-container/Dockerfile +++ b/packages/techdocs-container/Dockerfile @@ -12,9 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM python:3.7.7-alpine3.12 +FROM python:3.8-alpine -RUN apk update && apk --no-cache add gcc musl-dev -RUN pip install --upgrade pip && pip install mkdocs-techdocs-core==0.0.3 mkdocs==1.1.2 mkdocs-material==5.3.2 mkdocs-monorepo-plugin==0.4.5 pymdown-extensions==7.1 + +RUN apk update && apk --no-cache add gcc musl-dev openjdk11-jdk curl graphviz ttf-dejavu fontconfig + +RUN curl -L http://sourceforge.net/projects/plantuml/files/plantuml.1.2020.16.jar/download > /opt/plantuml.jar +RUN pip install --upgrade pip && pip install mkdocs-techdocs-core==0.0.7 + +# Create script to call plantuml.jar from a location in path + +RUN echo $'#!/bin/sh\n\njava -jar '/opt/plantuml.jar' ${@}' >> /usr/local/bin/plantuml +RUN chmod 755 /usr/local/bin/plantuml ENTRYPOINT [ "mkdocs" ] diff --git a/packages/techdocs-container/README.md b/packages/techdocs-container/README.md index 2bfa9a1d26..aa1bf15974 100644 --- a/packages/techdocs-container/README.md +++ b/packages/techdocs-container/README.md @@ -2,8 +2,6 @@ This is the Docker container that powers the creation of static documentation sites that are supported by [TechDocs](https://github.com/spotify/backstage/blob/master/plugins/techdocs). -**WIP: This is a work in progress. It is not ready for use yet. Follow our progress on [the Backstage Discord](https://discord.gg/MUpMjP2) under #docs-like-code or on [our GitHub Milestone](https://github.com/spotify/backstage/milestone/15).** - ## Getting Started Using the TechDocs CLI, we can invoke the latest version of `techdocs-container` via Docker Hub: diff --git a/packages/techdocs-container/bin/scripts/plantuml b/packages/techdocs-container/bin/scripts/plantuml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/techdocs-container/mock-docs/docs/index.md b/packages/techdocs-container/mock-docs/docs/index.md index e8ba4e2d6d..2586b86cfb 100644 --- a/packages/techdocs-container/mock-docs/docs/index.md +++ b/packages/techdocs-container/mock-docs/docs/index.md @@ -30,3 +30,34 @@ This is a b c. ## xyz This is x y z. + +# The attack plan + +{% dot attack_plan.svg + digraph G { + rankdir=LR + Earth [peripheries=2] + Mars + Earth -> Mars + } +%} + +```graphviz dot attack_plan.svg +digraph G { + rankdir=LR + Earth [peripheries=2] + Mars + Earth -> Mars +} +``` + +# PlantUML Samples + +```plantuml classes="uml myDiagram" alt="Diagram placeholder" title="My diagram" +@startuml + Goofy -> MickeyMouse: calls + Goofy <-- MickeyMouse: responds +@enduml +``` + +:bulb: diff --git a/packages/techdocs-container/mock-docs/mkdocs.yml b/packages/techdocs-container/mock-docs/mkdocs.yml index 8639c6abef..d769fddfcf 100644 --- a/packages/techdocs-container/mock-docs/mkdocs.yml +++ b/packages/techdocs-container/mock-docs/mkdocs.yml @@ -1,7 +1,7 @@ site_name: 'mock-docs' site_description: 'mock-docs site description' -nav: +nav: - Home: index.md - SubDocs: '!include ./sub-docs/mkdocs.yml' diff --git a/packages/techdocs-container/mock-docs/sub-docs/mkdocs.yml b/packages/techdocs-container/mock-docs/sub-docs/mkdocs.yml index 09504c1b31..4490ddbefa 100644 --- a/packages/techdocs-container/mock-docs/sub-docs/mkdocs.yml +++ b/packages/techdocs-container/mock-docs/sub-docs/mkdocs.yml @@ -1,4 +1,4 @@ site_name: subdocs nav: - - Home 2: "index.md" + - Home 2: 'index.md' diff --git a/packages/techdocs-container/techdocs-core/README.md b/packages/techdocs-container/techdocs-core/README.md index 9608780bd2..36f1437a54 100644 --- a/packages/techdocs-container/techdocs-core/README.md +++ b/packages/techdocs-container/techdocs-core/README.md @@ -47,3 +47,27 @@ python -m black src/ ``` **Note:** This will write to all Python files in `src/` with the formatted code. If you would like to only check to see if it passes, simply append the `--check` flag. + +## Changelog + +### 0.0.7 + +- Fix an issue with configuration of emoji support + +### 0.0.6 + +- Further adjustments to versions to find ones that are compatible + +### 0.0.5 + +- Downgrade some versions of markdown extensions to versions that are more stable + +### 0.0.4 + +- Added support for more mkdocs extensions + - mkdocs-material + - mkdocs-monorepo-plugin + - plantuml-markdown + - markdown_inline_graphviz_extension + - pygments + - pymdown-extensions diff --git a/packages/techdocs-container/techdocs-core/requirements.txt b/packages/techdocs-container/techdocs-core/requirements.txt index 2a13d1a9da..36daaab4d7 100644 --- a/packages/techdocs-container/techdocs-core/requirements.txt +++ b/packages/techdocs-container/techdocs-core/requirements.txt @@ -2,6 +2,12 @@ # Note: if you update this, also update `install_requires` in setup.py # https://github.com/mkdocs/mkdocs mkdocs==1.1.2 +mkdocs-material==5.3.2 +mkdocs-monorepo-plugin==0.4.5 +plantuml-markdown==3.1.2 +markdown_inline_graphviz_extension==1.1 +pygments==2.6.1 +pymdown-extensions==8.0.0 # The linter using for Python # Note: This requires Python 3.6+ to run, but can format Python 2 code too. diff --git a/packages/techdocs-container/techdocs-core/setup.py b/packages/techdocs-container/techdocs-core/setup.py index 682825624c..3e3fa47caa 100644 --- a/packages/techdocs-container/techdocs-core/setup.py +++ b/packages/techdocs-container/techdocs-core/setup.py @@ -18,7 +18,7 @@ from setuptools import setup, find_packages setup( name='mkdocs-techdocs-core', - version='0.0.3', + version='0.0.7', description='A Mkdocs package that contains TechDocs defaults', long_description='', keywords='mkdocs', @@ -28,7 +28,13 @@ setup( license='Apache-2.0', python_requires='>=3.7', install_requires=[ - 'mkdocs>=1.1.2' + 'mkdocs>=1.1.2', + 'mkdocs-material==5.3.2', + 'mkdocs-monorepo-plugin==0.4.5', + 'plantuml-markdown==3.1.2', + 'markdown_inline_graphviz_extension==1.1', + 'pygments==2.6.1', + 'pymdown-extensions==8.0.0' ], classifiers=[ 'Development Status :: 1 - Planning', diff --git a/packages/techdocs-container/techdocs-core/src/core.py b/packages/techdocs-container/techdocs-core/src/core.py index 0bd992ab7e..69e39d401b 100644 --- a/packages/techdocs-container/techdocs-core/src/core.py +++ b/packages/techdocs-container/techdocs-core/src/core.py @@ -18,6 +18,7 @@ from mkdocs.plugins import BasePlugin, PluginCollection from mkdocs.theme import Theme from mkdocs.contrib.search import SearchPlugin from mkdocs_monorepo_plugin.plugin import MonorepoPlugin +from pymdownx.emoji import to_svg import tempfile import os @@ -76,9 +77,7 @@ class TechDocsCore(BasePlugin): config["markdown_extensions"].append("pymdownx.critic") config["markdown_extensions"].append("pymdownx.details") config["markdown_extensions"].append("pymdownx.emoji") - config["mdx_configs"]["pymdownx.emoji"] = { - "emoji_generator": "!!python/name:pymdownx.emoji.to_svg", - } + config["mdx_configs"]["pymdownx.emoji"] = {"emoji_generator": to_svg} config["markdown_extensions"].append("pymdownx.inlinehilite") config["markdown_extensions"].append("pymdownx.magiclink") config["markdown_extensions"].append("pymdownx.mark") @@ -90,4 +89,7 @@ class TechDocsCore(BasePlugin): } config["markdown_extensions"].append("pymdownx.tilde") + config["markdown_extensions"].append("markdown_inline_graphviz") + config["markdown_extensions"].append("plantuml_markdown") + return config diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json index 638513af5c..8439bee779 100644 --- a/packages/test-utils-core/package.json +++ b/packages/test-utils-core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils-core", "description": "Utilities to test Backstage core", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 35bbb7d0b0..8567af8c14 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public", @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/core-api": "^0.1.1-alpha.18", - "@backstage/test-utils-core": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/core-api": "^0.1.1-alpha.21", + "@backstage/test-utils-core": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts index 9b22a352e8..7bf50314d0 100644 --- a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts +++ b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - ErrorApi, - ErrorContext, - errorApiRef, - Observable, -} from '@backstage/core-api'; +import { ErrorApi, ErrorContext, Observable } from '@backstage/core-api'; type Options = { collect?: boolean; @@ -40,12 +35,6 @@ const nullObservable = { }; export class MockErrorApi implements ErrorApi { - static factory = { - implements: errorApiRef, - deps: {}, - factory: () => new MockErrorApi(), - }; - private readonly errors = new Array(); private readonly waiters = new Set(); diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts index a3a4ef16d6..006b44a49c 100644 --- a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts @@ -17,7 +17,6 @@ import { Observable, StorageApi, - storageApiRef, StorageValueChange, } from '@backstage/core-api'; import ObservableImpl from 'zen-observable'; @@ -25,12 +24,6 @@ import ObservableImpl from 'zen-observable'; export type MockStorageBucket = { [key: string]: any }; export class MockStorageApi implements StorageApi { - static factory = { - implements: storageApiRef, - deps: {}, - factory: () => MockStorageApi.create(), - }; - private readonly namespace: string; private readonly data: MockStorageBucket; diff --git a/packages/test-utils/src/testUtils/appWrappers.test.tsx b/packages/test-utils/src/testUtils/appWrappers.test.tsx index 19c02c2158..464c547ecc 100644 --- a/packages/test-utils/src/testUtils/appWrappers.test.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx @@ -49,9 +49,6 @@ describe('wrapInTestApp', () => { expect.stringMatching( /^Warning: An update to %s inside a test was not wrapped in act\(...\)/, ), - expect.stringMatching( - /^Warning: An update to %s inside a test was not wrapped in act\(...\)/, - ), ]); }); diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index 2e3a73e346..182de1ef63 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -24,7 +24,7 @@ import privateExports, { } from '@backstage/core-api'; import { RenderResult } from '@testing-library/react'; import { renderWithEffects } from '@backstage/test-utils-core'; -import { createMockApiRegistry } from './mockApiRegistry'; +import { mockApis } from './mockApis'; const { PrivateAppImpl } = privateExports; @@ -58,10 +58,9 @@ export function wrapInTestApp( options: TestAppOptions = {}, ): ReactElement { const { routeEntries = ['/'] } = options; - const apis = createMockApiRegistry(); const app = new PrivateAppImpl({ - apis, + apis: [], components: { NotFoundErrorPage, BootErrorPage, @@ -80,6 +79,7 @@ export function wrapInTestApp( variant: 'light', }, ], + defaultApis: mockApis, }); let Wrapper: ComponentType; diff --git a/packages/test-utils/src/testUtils/mockApiRegistry.ts b/packages/test-utils/src/testUtils/mockApis.ts similarity index 70% rename from packages/test-utils/src/testUtils/mockApiRegistry.ts rename to packages/test-utils/src/testUtils/mockApis.ts index 15733ead88..e05a8e6cac 100644 --- a/packages/test-utils/src/testUtils/mockApiRegistry.ts +++ b/packages/test-utils/src/testUtils/mockApis.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -import { ApiTestRegistry } from '@backstage/core-api'; +import { + storageApiRef, + errorApiRef, + createApiFactory, +} from '@backstage/core-api'; import { MockErrorApi, MockStorageApi } from './apis'; -export function createMockApiRegistry(): ApiTestRegistry { - const registry = new ApiTestRegistry(); - - registry.register(MockErrorApi.factory); - registry.register(MockStorageApi.factory); - - return registry; -} +export const mockApis = [ + createApiFactory(errorApiRef, new MockErrorApi()), + createApiFactory(storageApiRef, MockStorageApi.create()), +]; diff --git a/packages/test-utils/src/testUtils/mockBreakpoint.ts b/packages/test-utils/src/testUtils/mockBreakpoint.ts index 598ef654e8..72406c88a5 100644 --- a/packages/test-utils/src/testUtils/mockBreakpoint.ts +++ b/packages/test-utils/src/testUtils/mockBreakpoint.ts @@ -54,6 +54,8 @@ export default function mockBreakpoint(initialBreakpoint: Breakpoint = 'xl') { let currentBreakpoint = initialBreakpoint; const queries = Array(); + const previousMatchMedia: any = (window as any).matchMedia; + (window as any).matchMedia = (query: string): QueryList => { const listeners = new Set(); @@ -85,7 +87,7 @@ export default function mockBreakpoint(initialBreakpoint: Breakpoint = 'xl') { }); }, remove() { - delete window.matchMedia; + (window as any).matchMedia = previousMatchMedia; }, }; } diff --git a/packages/theme/package.json b/packages/theme/package.json index f4eafa58ff..436455633b 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public", @@ -28,10 +28,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@material-ui/core": "^4.9.1" + "@material-ui/core": "^4.11.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18" + "@backstage/cli": "^0.1.1-alpha.21" }, "files": [ "dist" diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts index dc68db11d6..00a485bb41 100644 --- a/packages/theme/src/baseTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -192,10 +192,31 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides { }, MuiChip: { root: { + backgroundColor: '#D9D9D9', // By default there's no margin, but it's usually wanted, so we add some trailing margin marginRight: theme.spacing(1), marginBottom: theme.spacing(1), }, + label: { + color: theme.palette.grey[900], + lineHeight: `${theme.spacing(2.5)}px`, + fontWeight: theme.typography.fontWeightMedium, + fontSize: `${theme.spacing(1.75)}px`, + }, + labelSmall: { + fontSize: `${theme.spacing(1.5)}px`, + }, + deleteIcon: { + color: theme.palette.grey[500], + width: `${theme.spacing(3)}px`, + height: `${theme.spacing(3)}px`, + margin: `0 ${theme.spacing(0.75)}px 0 -${theme.spacing(0.75)}px`, + }, + deleteIconSmall: { + width: `${theme.spacing(2)}px`, + height: `${theme.spacing(2)}px`, + margin: `0 ${theme.spacing(0.5)}px 0 -${theme.spacing(0.5)}px`, + }, }, MuiCardHeader: { root: { diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index eddd6a4ede..73d6c13859 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -15,7 +15,7 @@ */ import { createTheme } from './baseTheme'; -import { blue, yellow } from '@material-ui/core/colors'; +import { yellow } from '@material-ui/core/colors'; export const lightTheme = createTheme({ palette: { @@ -39,7 +39,7 @@ export const lightTheme = createTheme({ }, }, primary: { - main: blue[500], + main: '#2E77D0', }, banner: { info: '#2E77D0', @@ -95,7 +95,7 @@ export const darkTheme = createTheme({ }, }, primary: { - main: blue[500], + main: '#2E77D0', }, banner: { info: '#2E77D0', diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md index 7ea87004c3..17f354e997 100644 --- a/plugins/api-docs/README.md +++ b/plugins/api-docs/README.md @@ -3,7 +3,26 @@ WORK IN PROGRESS This is an extension for the catalog plugin that provides components to discover and display API entities. +APIs define the interface between components, see the [system model](https://backstage.io/docs/features/software-catalog/system-model) for details. +They are defined in machine readable formats and provide a human readable documentation. + +The plugin provides a standalone list of APIs, as well as an integration into the API tab of a catalog entity. + +![Standalone API list](./docs/api_list.png) +![OpenAPI Definition](./docs/openapi_definition.png) +![Integration into components](./docs/entity_tab_api.png) + +Right now, the following API formats are supported: + +- [OpenAPI](https://swagger.io/specification/) 2 & 3 +- [AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/) +- [GraphQL](https://graphql.org/learn/schema/) + +Other formats are displayed as plain text, but this can easily be extented. + +To fill the catalog with APIs, [provide entities of kind API](https://backstage.io/docs/features/software-catalog/descriptor-format#kind-api). +To link that an component implements an API, see [`implementsApis` property on components](https://backstage.io/docs/features/software-catalog/descriptor-format#specimplementsapis-optional). ## Links -- (The Backstage homepage)[https://backstage.io] +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/api-docs/docs/api_list.png b/plugins/api-docs/docs/api_list.png new file mode 100644 index 0000000000..abc948b3c6 Binary files /dev/null and b/plugins/api-docs/docs/api_list.png differ diff --git a/plugins/api-docs/docs/entity_tab_api.png b/plugins/api-docs/docs/entity_tab_api.png new file mode 100644 index 0000000000..1ab9de02cd Binary files /dev/null and b/plugins/api-docs/docs/entity_tab_api.png differ diff --git a/plugins/api-docs/docs/openapi_definition.png b/plugins/api-docs/docs/openapi_definition.png new file mode 100644 index 0000000000..af04946b19 Binary files /dev/null and b/plugins/api-docs/docs/openapi_definition.png differ diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index a599e7e322..25e5c6b900 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,10 +1,9 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, "publishConfig": { "access": "public", "main": "dist/index.esm.js", @@ -21,30 +20,34 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.18", - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/plugin-catalog": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@kyma-project/asyncapi-react": "^0.6.1", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/plugin-catalog": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@kyma-project/asyncapi-react": "^0.11.0", "@material-icons/font": "^1.0.2", - "@material-ui/core": "^4.9.1", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "graphiql": "^1.0.0-alpha.10", + "graphql": "^15.3.0", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", "swagger-ui-react": "^3.31.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", - "@backstage/test-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", + "@types/react": "^16.9", "@types/swagger-ui-react": "^3.23.3", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx b/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx new file mode 100644 index 0000000000..5383fc0570 --- /dev/null +++ b/plugins/api-docs/src/catalog/EntityPageApi/EntityPageApi.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentEntity, Entity } from '@backstage/catalog-model'; +import { Progress } from '@backstage/core'; +import React from 'react'; +import { Grid } from '@material-ui/core'; +import { + ApiDefinitionCard, + useComponentApiEntities, + useComponentApiNames, +} from '../../components'; + +type Props = { + entity: Entity; +}; + +export const EntityPageApi = ({ entity }: Props) => { + const apiNames = useComponentApiNames(entity as ComponentEntity); + + const { apiEntities, loading } = useComponentApiEntities({ + entity: entity as ComponentEntity, + }); + + if (loading) { + return ; + } + + return ( + + {apiNames.map(api => ( + + + + ))} + + ); +}; diff --git a/plugins/circleci/src/components/Settings/index.ts b/plugins/api-docs/src/catalog/EntityPageApi/index.ts similarity index 92% rename from plugins/circleci/src/components/Settings/index.ts rename to plugins/api-docs/src/catalog/EntityPageApi/index.ts index c04ded6dea..1d382e01de 100644 --- a/plugins/circleci/src/components/Settings/index.ts +++ b/plugins/api-docs/src/catalog/EntityPageApi/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { default as Settings } from './Settings'; + +export { EntityPageApi } from './EntityPageApi'; diff --git a/plugins/api-docs/src/catalog/Router.tsx b/plugins/api-docs/src/catalog/Router.tsx new file mode 100644 index 0000000000..71640954b3 --- /dev/null +++ b/plugins/api-docs/src/catalog/Router.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { Route, Routes } from 'react-router'; +import { WarningPanel } from '@backstage/core'; +import { catalogRoute } from '../routes'; +import { EntityPageApi } from './EntityPageApi'; + +const isPluginApplicableToEntity = (entity: Entity) => { + return ((entity.spec?.implementsApis as string[]) || []).length > 0; +}; + +export const Router = ({ entity }: { entity: Entity }) => + // TODO(shmidt-i): move warning to a separate standardized component + !isPluginApplicableToEntity(entity) ? ( + + The entity doesn't implement any APIs. + + ) : ( + + } + /> + ) + + ); diff --git a/plugins/api-docs/src/catalog/index.ts b/plugins/api-docs/src/catalog/index.ts new file mode 100644 index 0000000000..4c177df914 --- /dev/null +++ b/plugins/api-docs/src/catalog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Router } from './Router'; diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx index af101f0194..055363ae1f 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx @@ -14,21 +14,102 @@ * limitations under the License. */ -import { ApiEntityV1alpha1 } from '@backstage/catalog-model'; -import { InfoCard } from '@backstage/core'; -import React, { FC } from 'react'; -import { ApiDefinitionWidget } from '../ApiDefinitionWidget/ApiDefinitionWidget'; +import { ApiEntity } from '@backstage/catalog-model'; +import { CardTab, TabbedCard } from '@backstage/core'; +import { Alert } from '@material-ui/lab'; +import React from 'react'; +import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget'; +import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget'; +import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget'; +import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget'; -export const ApiDefinitionCard: FC<{ - title?: string; - apiEntity: ApiEntityV1alpha1; -}> = ({ title, apiEntity }) => { - const type = apiEntity?.spec?.type || ''; - const definition = apiEntity?.spec?.definition || ''; +type ApiDefinitionWidget = { + type: string; + title: string; + component: (definition: string) => React.ReactElement; + rawLanguage?: string; +}; + +export function defaultDefinitionWidgets(): ApiDefinitionWidget[] { + return [ + { + type: 'openapi', + title: 'OpenAPI', + rawLanguage: 'yaml', + component: definition => ( + + ), + }, + { + type: 'asyncapi', + title: 'AsyncAPI', + rawLanguage: 'yaml', + component: definition => ( + + ), + }, + { + type: 'graphql', + title: 'GraphQL', + rawLanguage: 'graphql', + component: definition => ( + + ), + }, + ]; +} + +type Props = { + apiEntity?: ApiEntity; + definitionWidgets?: ApiDefinitionWidget[]; +}; + +const defaultProps = { + definitionWidgets: defaultDefinitionWidgets(), +}; + +export const ApiDefinitionCard = (props: Props) => { + const { apiEntity, definitionWidgets } = { + ...defaultProps, + ...props, + }; + + if (!apiEntity) { + return Could not fetch the API; + } + + const definitionWidget = definitionWidgets.find( + d => d.type === apiEntity.spec.type, + ); + + if (definitionWidget) { + return ( + + + {definitionWidget.component(apiEntity.spec.definition)} + + + + + + ); + } return ( - - - + + + , + ]} + /> ); }; diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/index.ts b/plugins/api-docs/src/components/ApiDefinitionCard/index.ts new file mode 100644 index 0000000000..b2a2f3af62 --- /dev/null +++ b/plugins/api-docs/src/components/ApiDefinitionCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ApiDefinitionCard } from './ApiDefinitionCard'; diff --git a/plugins/api-docs/src/components/ApiDefinitionWidget/ApiDefinitionWidget.tsx b/plugins/api-docs/src/components/ApiDefinitionWidget/ApiDefinitionWidget.tsx deleted file mode 100644 index 5b1ddd7880..0000000000 --- a/plugins/api-docs/src/components/ApiDefinitionWidget/ApiDefinitionWidget.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { FC } from 'react'; -import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget/AsyncApiDefinitionWidget'; -import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget/OpenApiDefinitionWidget'; -import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget/PlainApiDefinitionWidget'; - -export const ApiDefinitionWidget: FC<{ - type: string; - definition: string; -}> = ({ type, definition }) => { - switch (type) { - case 'openapi': - return ; - - case 'asyncapi': - return ; - - default: - return ; - } -}; diff --git a/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx b/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx index 5e08af7e8e..b7e9996cc0 100644 --- a/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx +++ b/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model'; +import { ApiEntity, Entity } from '@backstage/catalog-model'; import { Content, errorApiRef, @@ -25,16 +25,16 @@ import { Progress, useApi, } from '@backstage/core'; -// TODO: Circular ref import { catalogApiRef } from '@backstage/plugin-catalog'; import { Box } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import React, { FC, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; -import { ApiDefinitionCard } from '../ApiDefinitionCard/ApiDefinitionCard'; +import { ApiDefinitionCard } from '../ApiDefinitionCard'; const REDIRECT_DELAY = 1000; + function headerProps( kind: string, namespace: string | undefined, @@ -59,15 +59,18 @@ export const getPageTheme = (entity?: Entity): PageTheme => { return pageTheme[themeKey] ?? pageTheme.home; }; -const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({ - title, -}) => ( +type EntityPageTitleProps = { + title: string; + entity: Entity | undefined; +}; + +const EntityPageTitle = ({ title }: EntityPageTitleProps) => ( {title} ); -export const ApiEntityPage: FC<{}> = () => { +export const ApiEntityPage = () => { const { optionalNamespaceAndName } = useParams() as { optionalNamespaceAndName: string; }; @@ -122,7 +125,7 @@ export const ApiEntityPage: FC<{}> = () => { {entity && ( <> - + )} diff --git a/plugins/api-docs/src/components/ApiEntityPage/index.ts b/plugins/api-docs/src/components/ApiEntityPage/index.ts new file mode 100644 index 0000000000..561350744b --- /dev/null +++ b/plugins/api-docs/src/components/ApiEntityPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ApiEntityPage } from './ApiEntityPage'; diff --git a/plugins/api-docs/src/components/ApiCatalogPage/ApiCatalogLayout.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx similarity index 84% rename from plugins/api-docs/src/components/ApiCatalogPage/ApiCatalogLayout.tsx rename to plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx index d846e2b0e2..a111007278 100644 --- a/plugins/api-docs/src/components/ApiCatalogPage/ApiCatalogLayout.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx @@ -21,17 +21,15 @@ type Props = { children?: React.ReactNode; }; -const ApiCatalogLayout = ({ children }: Props) => { +export const ApiExplorerLayout = ({ children }: Props) => { return (
{children} ); }; - -export default ApiCatalogLayout; diff --git a/plugins/api-docs/src/components/ApiCatalogPage/ApiCatalogPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx similarity index 93% rename from plugins/api-docs/src/components/ApiCatalogPage/ApiCatalogPage.test.tsx rename to plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index 19cbaf6038..639f1e3d9c 100644 --- a/plugins/api-docs/src/components/ApiCatalogPage/ApiCatalogPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -16,12 +16,11 @@ import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; -// TODO: Circular ref! import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; -import { ApiCatalogPage } from './ApiCatalogPage'; +import { ApiExplorerPage } from './ApiExplorerPage'; describe('ApiCatalogPage', () => { const catalogApi: Partial = { @@ -64,7 +63,7 @@ describe('ApiCatalogPage', () => { // related to some theme issues in mui-table // https://github.com/mbrn/material-table/issues/1293 it('should render', async () => { - const { findByText } = renderWrapped(); + const { findByText } = renderWrapped(); expect(await findByText(/APIs \(2\)/)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ApiCatalogPage/ApiCatalogPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx similarity index 59% rename from plugins/api-docs/src/components/ApiCatalogPage/ApiCatalogPage.tsx rename to plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index f624ec9f92..b6440edc3a 100644 --- a/plugins/api-docs/src/components/ApiCatalogPage/ApiCatalogPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -14,32 +14,42 @@ * limitations under the License. */ -import { Content, useApi } from '@backstage/core'; -// TODO: Circular ref +import { Content, ContentHeader, SupportButton, useApi } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog'; +import { Button } from '@material-ui/core'; import React from 'react'; +import { Link as RouterLink } from 'react-router-dom'; import { useAsync } from 'react-use'; -import { ApiCatalogTable } from '../ApiCatalogTable/ApiCatalogTable'; -import ApiCatalogLayout from './ApiCatalogLayout'; +import { ApiExplorerTable } from '../ApiExplorerTable'; +import { ApiExplorerLayout } from './ApiExplorerLayout'; -const CatalogPageContents = () => { +export const ApiExplorerPage = () => { const catalogApi = useApi(catalogApiRef); const { loading, error, value: matchingEntities } = useAsync(() => { return catalogApi.getEntities({ kind: 'API' }); }, [catalogApi]); return ( - + - + + All your APIs + + - + ); }; - -export const ApiCatalogPage = () => ; diff --git a/plugins/api-docs/src/components/ApiExplorerPage/index.ts b/plugins/api-docs/src/components/ApiExplorerPage/index.ts new file mode 100644 index 0000000000..67f672f9a9 --- /dev/null +++ b/plugins/api-docs/src/components/ApiExplorerPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ApiExplorerPage } from './ApiExplorerPage'; diff --git a/plugins/api-docs/src/components/ApiCatalogTable/ApiCatalogTable.test.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.test.tsx similarity index 95% rename from plugins/api-docs/src/components/ApiCatalogTable/ApiCatalogTable.test.tsx rename to plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.test.tsx index 67f6562a56..9cd7b01945 100644 --- a/plugins/api-docs/src/components/ApiCatalogTable/ApiCatalogTable.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.test.tsx @@ -18,7 +18,7 @@ import { Entity } from '@backstage/catalog-model'; import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import * as React from 'react'; -import { ApiCatalogTable } from './ApiCatalogTable'; +import { ApiExplorerTable } from './ApiExplorerTable'; const entites: Entity[] = [ { @@ -42,7 +42,7 @@ describe('ApiCatalogTable component', () => { it('should render error message when error is passed in props', async () => { const rendered = render( wrapInTestApp( - { it('should display entity names when loading has finished and no error occurred', async () => { const rendered = render( wrapInTestApp( - [] = [ + { + title: 'Name', + field: 'metadata.name', + highlight: true, + render: (entity: any) => ( + + {entity.metadata.name} + + ), + }, + { + title: 'Owner', + field: 'spec.owner', + }, + { + title: 'Lifecycle', + field: 'spec.lifecycle', + }, + { + title: 'Type', // TODO: Resolve the type display name using the API from https://github.com/spotify/backstage/pull/2451 + field: 'spec.type', + }, + { + title: 'Description', + field: 'metadata.description', + }, + { + title: 'Tags', + field: 'metadata.tags', + cellStyle: { + padding: '0px 16px 0px 20px', + }, + render: (entity: Entity) => ( + <> + {entity.metadata.tags && + entity.metadata.tags.map(t => ( + + ))} + + ), + }, +]; + +type ExplorerTableProps = { + entities: Entity[]; + titlePreamble: string; + loading: boolean; + error?: any; +}; + +export const ApiExplorerTable = ({ + entities, + loading, + error, + titlePreamble, +}: ExplorerTableProps) => { + if (error) { + return ( +
+ + Error encountered while fetching catalog entities. {error.toString()} + +
+ ); + } + + return ( + + isLoading={loading} + columns={columns} + options={{ + paging: false, + actionsColumnIndex: -1, + loadingType: 'linear', + showEmptyDataSourceMessage: !loading, + }} + title={`${titlePreamble} (${(entities && entities.length) || 0})`} + data={entities} + /> + ); +}; diff --git a/plugins/github-actions/src/components/WorkflowRunsPage/index.ts b/plugins/api-docs/src/components/ApiExplorerTable/index.ts similarity index 91% rename from plugins/github-actions/src/components/WorkflowRunsPage/index.ts rename to plugins/api-docs/src/components/ApiExplorerTable/index.ts index ef511763f7..a9c79861e8 100644 --- a/plugins/github-actions/src/components/WorkflowRunsPage/index.ts +++ b/plugins/api-docs/src/components/ApiExplorerTable/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { WorkflowRunsPage } from './WorkflowRunsPage'; +export { ApiExplorerTable } from './ApiExplorerTable'; diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx index 2b5e697e51..ac5dedfd9c 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx @@ -15,11 +15,136 @@ */ import AsyncApi from '@kyma-project/asyncapi-react'; -import React, { FC } from 'react'; -import './style.css'; +import React from 'react'; +import { makeStyles, fade } from '@material-ui/core/styles'; +import '@kyma-project/asyncapi-react/lib/styles/fiori.css'; -export const AsyncApiDefinitionWidget: FC<{ - definition: any; -}> = ({ definition }) => { - return ; +const useStyles = makeStyles(theme => ({ + root: { + '& .asyncapi': { + 'font-family': 'inherit', + background: 'none', + }, + '& h2': { + ...theme.typography.h6, + }, + '& .text-teal': { + color: theme.palette.primary.main, + }, + '& button': { + ...theme.typography.button, + background: 'none', + boxSizing: 'border-box', + minWidth: 64, + borderRadius: theme.shape.borderRadius, + transition: theme.transitions.create( + ['background-color', 'box-shadow', 'border'], + { + duration: theme.transitions.duration.short, + }, + ), + padding: '5px 15px', + color: theme.palette.primary.main, + border: `1px solid ${fade(theme.palette.primary.main, 0.5)}`, + '&:hover': { + textDecoration: 'none', + '&.Mui-disabled': { + backgroundColor: 'transparent', + }, + border: `1px solid ${theme.palette.primary.main}`, + backgroundColor: fade( + theme.palette.primary.main, + theme.palette.action.hoverOpacity, + ), + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }, + '&.Mui-disabled': { + color: theme.palette.action.disabled, + }, + }, + '& .asyncapi__collapse-button:hover': { + color: theme.palette.primary.main, + }, + '& button.asyncapi__toggle-button': { + 'min-width': 'inherit', + }, + '& .asyncapi__info-list li': { + 'border-color': theme.palette.primary.main, + '&:hover': { + color: theme.palette.text.primary, + 'border-color': theme.palette.primary.main, + 'background-color': theme.palette.primary.main, + }, + }, + '& .asyncapi__info-list li a': { + color: theme.palette.primary.main, + '&:hover': { + color: theme.palette.getContrastText(theme.palette.primary.main), + }, + }, + '& .asyncapi__enum': { + color: theme.palette.secondary.main, + }, + '& .asyncapi__toggle-arrow:before': { + content: '">"', + 'font-family': 'inherit', + }, + '& .asyncapi__anchor-icon:before': { + content: '"🔗"', + 'font-family': 'inherit', + }, + '& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div': { + 'background-color': 'inherit', + }, + '& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header': { + 'background-color': 'inherit', + color: theme.palette.text.primary, + }, + '& .asyncapi__additional-properties-notice': { + color: theme.palette.text.hint, + }, + '& .asyncapi__code, .asyncapi__code-pre': { + background: theme.palette.background.default, + }, + '& .asyncapi__schema-example-header-title': { + color: theme.palette.text.secondary, + }, + '& .asyncapi__message-headers-header, .asyncapi__message-payload-header, .asyncapi__server-variables-header, .asyncapi__server-security-header': { + 'background-color': 'inherit', + color: theme.palette.text.secondary, + }, + '& .asyncapi__table-header': { + background: theme.palette.background.default, + }, + '& .asyncapi__table-body': { + color: theme.palette.text.primary, + }, + '& .asyncapi__server-security-flow': { + background: theme.palette.background.default, + border: 'none', + }, + '& .asyncapi__server-security-flows-list a': { + color: theme.palette.primary.main, + }, + '& .asyncapi__table-row--nested': { + color: theme.palette.text.secondary, + }, + }, +})); + +type Props = { + definition: string; +}; + +export const AsyncApiDefinitionWidget = ({ definition }: Props) => { + const classes = useStyles(); + + return ( +
+ +
+ ); }; diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/index.ts b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/index.ts new file mode 100644 index 0000000000..ecafd7d756 --- /dev/null +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget'; diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/style.css b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/style.css deleted file mode 100644 index 0237c24d52..0000000000 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/style.css +++ /dev/null @@ -1,1273 +0,0 @@ -/* - * Copyright (c) 2018-2019 SAP SE or an SAP affiliate company. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -.asyncapi { - /*font-family: '72';*/ - font-size: 14px; - line-height: 20px; - font-weight: 400; - -webkit-font-smoothing: antialiased; - font-smoothing: antialiased; - /*background: #f3f4f5;*/ -} - -.asyncapi__toggle { - cursor: pointer; -} -.asyncapi__toggle > .asyncapi__toggle-body { - display: none; -} - -.asyncapi__toggle--expanded { - cursor: default; -} -.asyncapi__toggle--expanded > .asyncapi__toggle-body { - display: block; -} - -.asyncapi__toggle--no-children { -} -.asyncapi__toggle--no-children > .asyncapi__toggle-header { - cursor: default; -} - -.asyncapi__toggle-header { - position: relative; - display: flex; - flex-flow: row wrap; - align-items: center; - justify-content: center; - cursor: pointer; -} - -.asyncapi__toggle-header-content { - flex-grow: 1; - padding: 12px; -} -.asyncapi__toggle-header-content > h1, -.asyncapi__toggle-header-content > h2, -.asyncapi__toggle-header-content > h3, -.asyncapi__toggle-header-content > h4 { - margin: 0; - padding: 0; - display: flex; -} - -.asyncapi__toggle-button { - border: none; - height: 100%; - cursor: pointer; -} - -.asyncapi__toggle-arrow { - display: inline-block; - position: relative; - transform-origin: 50% 50%; - transition: 0.35s ease; - cursor: pointer; -} -.asyncapi__toggle-arrow:before { - content: '>'; - /*font-family: 'SAP-icons';*/ - font-size: 14px; -} -.asyncapi__toggle-arrow--expanded { - transform: rotate(-90deg); -} - -.asyncapi__components { -} - -.asyncapi__anchor { -} -.asyncapi__anchor:hover > .asyncapi__anchor-content { - text-decoration: underline; -} - -.asyncapi__anchor-content { - display: inline-block; -} - -.asyncapi__anchor-icon { - display: inline-block; - margin-left: 6px; -} -.asyncapi__anchor-icon:before { - content: '🔗'; - /*font-family: 'SAP-icons';*/ -} - -.asyncapi__markdown { -} -.asyncapi__markdown > div > ul { - margin: 0; - padding-left: 16px; -} -.asyncapi__markdown > div > ul { - margin: 0; - padding-left: 16px; -} -.asyncapi__markdown > div > p { - margin: 0; -} -.asyncapi__markdown > div > p > code { - display: inline-block; - font-weight: bold; - font-size: 10px; - line-height: 14px; - border-radius: 3px; - padding: 0px 5px; - text-align: center; - /*background: #e2eaf2;*/ - color: #18873d; -} - -.asyncapi__table { - margin: 0 0 20px 0; - width: 100%; - border-spacing: 0; - font-size: 13px; -} - -.asyncapi__table--nested { - margin: 10px 10px 10px auto; - width: calc(100% - 45px); - border-spacing: 0; - font-size: 13px; - border-radius: 5px; - border: solid 1px #d4d4d4; - /*background-color: #f9fafa;*/ -} - -.asyncapi__table-header { - width: 100%; - color: #939698; - /*background: #f9fafa;*/ - text-transform: uppercase; -} - -.asyncapi__table-header--nested { - color: #939698; - border-bottom: solid 1px #d4d4d4; - font-weight: bold; - text-align: left; - padding: 6px 0; - font-size: 12px; -} - -.asyncapi__table-header-title { - line-height: 30px; -} - -.asyncapi__table-header-title--nested { - color: #939698; -} -.asyncapi__table-header-title--nested > td { - border-bottom: solid 1px #d4d4d4; - padding: 8px 20px; - font-size: 12px; - color: #818487; -} - -.asyncapi__table-header-columns { - font-weight: lighter; - font-size: 11px; -} - -.asyncapi__table-header-columns--nested { - color: #939698; -} - -.asyncapi__table-header-column { - padding: 12px; - text-align: left; -} - -.asyncapi__table-header-column--nested { - width: 20%; - padding: 8px 20px; - font-size: 12px; - border-bottom: solid 1px #d4d4d4; -} - -.asyncapi__table-body { - color: #000; -} - -.asyncapi__table-body--nested { - color: #000; -} - -.asyncapi__table-row { -} - -.asyncapi__table-row--nested { - color: #333; - border-bottom: solid 1px #d4d4d4; -} -.asyncapi__table-row--nested:last-child > td { - border-bottom: none; -} - -.asyncapi__table-row-accordion { - display: none; -} - -.asyncapi__table-row-accordion--open { - display: table-row; -} - -.asyncapi__table-cell { - padding: 12px; - vertical-align: top; - border-bottom: 1px solid #efeff0; -} -.asyncapi__table-cell > p { - margin-top: 0; -} - -.asyncapi__table-cell--nested { - padding: 8px 20px; - vertical-align: top; - font-size: 13px; - border-bottom: solid 1px #d4d4d4; -} - -.asyncapi__tree-space { - display: inline-block; - width: 20px; -} - -.asyncapi__tree-leaf { - display: inline-block; - position: relative; - width: 25px; -} - -.asyncapi__tree-leaf:before { - content: ''; - position: absolute; - top: -15px; - width: 13px; - height: 10px; - border-left: #aaa 2px solid; - border-bottom: #aaa 2px solid; - border-radius: 0 0 0 70%; -} - -.asyncapi__badge { - display: inline-block; - font-weight: bold; - font-size: 11px; - line-height: 18px; - border-radius: 3px; - padding: 0px 5px; - text-align: center; - text-transform: uppercase; - /*background: #e2eaf2;*/ -} - -.asyncapi__badge--publish { - color: #18873d; -} - -.asyncapi__badge--subscribe { - color: #107ee3; -} - -.asyncapi__badge--deprecated { - margin-left: 10px; - color: #f59702; -} - -.asyncapi__badge--required { - font-size: 9px; - line-height: 14px; - color: #f59702; - border-radius: 3px; - margin-left: 10px; -} - -.asyncapi__badge--generated { - font-size: 9px; - line-height: 14px; - color: #18873d; - border-radius: 3px; - margin-left: 10px; -} - -.asyncapi__tag { - display: inline-block; - mix-blend-mode: multiply; - border-radius: 4px; - /*background-color: #e2eaf2;*/ - font-size: 11px; - font-family: 72; - font-weight: 300; - text-transform: uppercase; - font-weight: normal; - font-style: normal; - font-stretch: normal; - line-height: normal; - letter-spacing: normal; - color: #73787d; - padding: 3px 8px; - margin: 0 5px 0 0; -} - -.asyncapi__code { - border: solid 1px rgba(137, 145, 154, 0.675); - border-radius: 5px; - /*background: #fff;*/ -} - -.asyncapi__code-header { - padding: 12px 20px; - border-bottom: 1px solid rgba(137, 145, 154, 0.675); -} - -.asyncapi__code-header > h4 { - margin: 0; - color: #32363a; - font-size: 13px; -} - -.asyncapi__code-pre { - margin: 0; - font-size: 13px; - padding: 12px; - /*background: #fafafa;*/ - border-bottom-right-radius: 5px; - border-bottom-left-radius: 5px; -} - -.asyncapi__code-body { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; - margin: 0; - font-size: 12px; -} - -.asyncapi__info { - /*background: #fff;*/ - border-radius: 5px; - padding: 16px; - box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.16); -} - -.asyncapi__info-header-main { - display: flex; - flex-flow: row wrap; - align-items: center; - justify-content: center; -} -.asyncapi__info-header-main > h1 { - flex-grow: 1; - margin-top: 10px; - margin-bottom: 10px; -} - -.asyncapi__collapse-button { - border-radius: 0.25rem; - border: 1px solid #0071d4; - display: inline-block; - color: #0b74de; - font-weight: 700; - transition: all 0.125s ease-in; - padding: 10px 12px; - font-size: 14px; - cursor: pointer; -} -.asyncapi__collapse-button:hover { - background-color: #085caf; - color: #fff; -} -.asyncapi__collapse-button:focus { - outline: none; -} - -.asyncapi__info-header-version { - display: inline-block; - margin-left: 6px; -} - -.asyncapi__info-description { -} - -.asyncapi__info-list { - margin: 0 0 20px 0; - padding: 0; - list-style-type: none; -} -.asyncapi__info-list > li { - border-radius: 0.25rem; - border: 1px solid #0071d4; - margin: 6px 6px 0 0; - display: inline-block; - color: #0b74de; - font-weight: 500; - transition: all 0.125s ease-in; -} -.asyncapi__info-list > li:hover { - background-color: #085caf; -} -.asyncapi__info-list > li a { - padding: 3px 12px; - display: inline-block; - text-decoration: none; - color: #0b74de; - transition: all 0.125s ease-in; -} -.asyncapi__info-list > li:hover, -.asyncapi__info-list > li:hover a { - color: #fff; -} -.asyncapi__info-list .asyncapi__anchor:hover > .asyncapi__anchor-content { - text-decoration: none; -} - -.asyncapi__messages { -} -.asyncapi__messages > div { - margin-top: 24px; - /*background: #fff;*/ - border-radius: 5px; - padding: 16px; - box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.16); -} - -.asyncapi__messages-toggle .asyncapi__toggle-header-content { - padding: 0; -} -.asyncapi__messages-toggle--expanded > .asyncapi__toggle-header { - padding-bottom: 12px; -} - -.asyncapi__messages-header { -} -.asyncapi__messages-header > h2 { - margin: 0 0 24px 0; -} - -.asyncapi__messages-list { - margin: 0; - padding: 0; - list-style-type: none; -} - -.asyncapi__messages-list-item { - margin-bottom: 16px; -} -.asyncapi__messages-list-item:last-child { - margin-bottom: 0; -} - -.asyncapi__messages-oneOf-list { - margin: 0; - padding: 0; - list-style-type: none; -} - -.asyncapi__messages-oneOf-list-item { -} - -.asyncapi__message { - position: relative; - border-radius: 4px; - border: solid 1px rgba(151, 151, 151, 0.26); - /*background-color: #ffffff;*/ -} -.asyncapi__message:last-child { - margin-bottom: 0; -} - -.asyncapi__message-header { - padding: 12px; -} -.asyncapi__message-header > h3 { - color: #0b74de; - font-size: 14px; -} - -.asyncapi__message-header-title { - font-size: 14px; - margin-right: 10px; -} - -.asyncapi__message-header-summary { - font-size: 14px; - font-weight: 500; -} - -.asyncapi__message-header-deprecated-badge { -} - -.asyncapi__message-summary { -} - -.asyncapi__message-description { - padding: 12px; - font-size: 14px; - border-top: solid 1px rgba(151, 151, 151, 0.26); -} - -.asyncapi__message-headers { -} - -.asyncapi__message-headers-header { - color: #32363a; - /*background-color: #fafafa;*/ - border-top: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__message-headers-header > h4 { - padding: 12px; - margin: 0; -} - -.asyncapi__message-headers-schema { - margin: 0; -} -.asyncapi__message-headers-schema > .asyncapi__schema { - padding: 0; - border: none; -} -.asyncapi__message-headers-schema > .asyncapi__schema:before { - content: ''; - position: relative; - border: none; -} - -.asyncapi__message-payload-oneOf { -} - -.asyncapi__message-payload-toggle > .asyncapi__message-payload-header { - padding: 12px; - border: none; -} -.asyncapi__message-payload-toggle .asyncapi__message-payload-header h4 { - padding: 0; -} -.asyncapi__message-payload-toggle .asyncapi__message-payload-header { - border: none; - background-color: inherit; -} - -.asyncapi__message-payload-oneOf-header { - color: #32363a; - /*background-color: #fafafa;*/ - border-top: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__message-payload-oneOf-header > h4 { - padding: 12px; - margin: 0; -} - -.asyncapi__message-payload-oneOf-list { - margin: 0; - padding: 16px; - list-style-type: none; -} - -.asyncapi__message-payload-oneOf-list-item { - margin-bottom: 16px; -} -.asyncapi__message-payload-oneOf-list-item:last-child { - margin-bottom: 0; -} -.asyncapi__message-payload-oneOf-list-item .asyncapi__message-payload { - position: relative; - border-radius: 4px; - border: solid 1px rgba(151, 151, 151, 0.26); - /*background-color: #ffffff;*/ -} -.asyncapi__message-payload-oneOf-list-item - .asyncapi__message-payload:last-child { - margin-bottom: 0; -} - -.asyncapi__message-payload-anyOf { -} - -.asyncapi__message-payload-anyOf-header { - color: #32363a; - /*background-color: #fafafa;*/ - border-top: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__message-payload-anyOf-header > h4 { - padding: 12px; - margin: 0; -} - -.asyncapi__message-payload-anyOf-list { - margin: 0; - padding: 16px; - list-style-type: none; -} - -.asyncapi__message-payload-anyOf-list-item { - margin-bottom: 16px; -} -.asyncapi__message-payload-anyOf-list-item:last-child { - margin-bottom: 0; -} -.asyncapi__message-payload-anyOf-list-item .asyncapi__message-payload { - position: relative; - border-radius: 4px; - border: solid 1px rgba(151, 151, 151, 0.26); - /*background-color: #ffffff;*/ -} -.asyncapi__message-payload-anyOf-list-item - .asyncapi__message-payload:last-child { - margin-bottom: 0; -} - -.asyncapi__message-payload { - margin: 0; -} - -.asyncapi__message-payload-header { - color: #32363a; - /*background-color: #fafafa;*/ - border-top: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__message-payload-header > h4 { - padding: 12px; - margin: 0; -} - -.asyncapi__message-payload-schema { - margin: 0; -} -.asyncapi__message-payload-schema > .asyncapi__schema { - padding: 0; - border: none; -} -.asyncapi__message-payload-schema > .asyncapi__schema:before { - content: ''; - position: relative; - border: none; -} - -.asyncapi__message-tags { - margin: 20px 0; -} - -.asyncapi__message-tags-header { - color: #32363a; -} -.asyncapi__message-tags-header > h4 { - margin: 0 0 8px 0; -} - -.asyncapi__message-tags-list { -} - -.asyncapi__message-tags-list-item { -} - -.asyncapi__schemas { -} -.asyncapi__schemas > div { - margin-top: 24px; - /*background: #fff;*/ - border-radius: 5px; - padding: 16px; - box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.16); -} - -.asyncapi__schemas-header { -} - -.asyncapi__schemas-toggle .asyncapi__toggle-header-content { - padding: 0; -} -.asyncapi__schemas-toggle--expanded .asyncapi__toggle-header { - padding-bottom: 12px; -} - -.asyncapi__schemas-list { - margin: 0; - padding: 0; - list-style-type: none; -} - -.asyncapi__schemas-list-item { - margin-bottom: 16px; -} -.asyncapi__schemas-list-item:last-child { - margin-bottom: 0; -} - -.asyncapi__schema { - position: relative; - border-radius: 4px; - border: solid 1px rgba(151, 151, 151, 0.26); - /*background-color: #ffffff;*/ -} -.asyncapi__schema:last-child { - margin-bottom: 0; -} - -.asyncapi__schema-header { - padding: 12px; -} - -.asyncapi__schema-header-title { - font-size: 14px; -} - -.asyncapi__schema-table { - border-top: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__schema-table > table { - margin: 0; -} - -.asyncapi__schema-example { - padding: 16px; -} - -.asyncapi__schema-example-header { -} - -.asyncapi__schema-example-header-title { -} - -.asyncapi__schema-example-header-generated-badge { - display: inline-block; -} - -.asyncapi__security { - margin-top: 24px; - /*background: #fff;*/ - border-radius: 5px; - padding: 16px; -} - -.asyncapi__security-header { -} -.asyncapi__security-header > h2 { - margin: 0 0 24px 0; -} - -.asyncapi__security-table { - margin: 0; -} - -.asyncapi__servers { -} -.asyncapi__servers > div { - margin-top: 24px; - /*background: #fff;*/ - border-radius: 5px; - padding: 16px; - box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.16); -} - -.asyncapi__servers-toggle .asyncapi__toggle-header-content { - padding: 0; -} -.asyncapi__servers-toggle--expanded .asyncapi__toggle-header { - padding-bottom: 12px; -} - -.asyncapi__servers-header { -} -.asyncapi__servers-header > h2 { - margin: 0 0 24px 0; -} - -.asyncapi__servers-list { - margin: 0; - padding: 0; - list-style-type: none; -} - -.asyncapi__servers-list-item { - margin-bottom: 16px; -} -.asyncapi__servers-list-item:last-child { - margin-bottom: 0; -} - -.asyncapi__server { - position: relative; - border-radius: 4px; - border: solid 1px rgba(151, 151, 151, 0.26); - /*background-color: #ffffff;*/ -} - -.asyncapi__server-header { - padding: 12px; -} - -.asyncapi__server-header-stage { - color: #107ee3; - margin-right: 6px; -} - -.asyncapi__server-header-protocol { - color: #18873d; - margin-right: 6px; -} - -.asyncapi__server-description { - border-top: solid 1px rgba(151, 151, 151, 0.26); - padding: 12px; -} - -.asyncapi__servers-table { - margin-bottom: 0; -} -.asyncapi__servers-table > .asyncapi__table { - margin: 0; -} - -.asyncapi__server-variables { -} - -.asyncapi__server-variables-header { - color: #32363a; - /*background-color: #fafafa;*/ - border-top: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__server-variables-header > h4 { - padding: 12px; - margin: 0; -} - -.asyncapi__server-variables-table { - border-top: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__server-variables-table > table { - margin: 0; -} -.asyncapi__server-variables-table .asyncapi__table-body > tr:last-child td { - border-bottom: none; -} - -.asyncapi__server-variables-table-cell { - padding: 0; - border-bottom: none; -} - -.asyncapi__server-expand-icon { - display: inline-block; - position: relative; - width: 10px; - height: 10px; - margin-right: 10px; - transform-origin: 50% 50%; - transition: 0.5s ease; - cursor: pointer; -} -.asyncapi__server-expand-icon:before { - content: '>'; - /*font-family: 'SAP-icons';*/ - position: absolute; - color: #0071d4; - font-size: 12px; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} - -.asyncapi__server-expand-icon--open { - transform: rotate(90deg); -} - -.asyncapi__server-variables-enum-list { - margin: 0 0 0 15px; - padding: 0; -} - -.asyncapi__server-variables-enum-list-item { -} - -.asyncapi__server-security { -} - -.asyncapi__server-security-header { - color: #32363a; - /*background-color: #fafafa;*/ - border-top: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__server-security-header > h4 { - padding: 12px; - margin: 0; -} - -.asyncapi__server-security-table { - border-top: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__server-security-table > table { - margin: 0; -} -.asyncapi__server-security-table .asyncapi__table-body > tr:last-child td { - border-bottom: none; -} - -.asyncapi__server-security-table-cell { - padding: 0; - border-bottom: none; -} - -.asyncapi__server-security-flows-list { - margin: 0; - padding: 0; - list-style-type: none; -} -.asyncapi__server-security-flows-list a { - color: #0b74de; -} - -.asyncapi__server-security-flows-list-item { - margin-top: 12px; -} -.asyncapi__server-security-flows-list-item:first-child { - margin-top: 0; -} - -.asyncapi__server-security-flow { - /*background: #fafafa;*/ - border: 1px solid #dae1e7; - padding: 12px; - border-radius: 5px; -} - -.asyncapi__server-security-flow-list { - margin: 0; - padding: 0; - list-style-type: none; -} - -.asyncapi__server-security-flow-list-item { - padding: 3px 0; -} -.asyncapi__server-security-flow-list-item > span { - margin-left: 12px; -} -.asyncapi__server-security-flow-list-item > a { - margin-left: 12px; -} - -.asyncapi__server-security-flows-table-cell { - border-bottom: solid 1px #d4d4d4; - padding: 12px; -} - -.asyncapi__server-security-oauth2 { - border-bottom: none; -} -.asyncapi__server-security-oauth2 > td { - border-bottom: none; -} - -.asyncapi__server-security-scopes-list { - margin: 0; - padding: 0; - list-style-type: none; - display: inline-block; - margin-left: 6px; -} - -.asyncapi__server-security-scopes-list-item { - display: inline-block; - margin-right: 6px; -} - -.asyncapi__server-security-scope { - display: inline-block; - font-weight: bold; - font-size: 11px; - line-height: 18px; - border-radius: 3px; - padding: 0px 5px; - text-align: center; - text-transform: uppercase; - /*background: #e2eaf2;*/ - color: #18873d; -} - -.asyncapi__channels { -} -.asyncapi__channels > div { - margin-top: 24px; - /*background: #fff;*/ - border-radius: 5px; - padding: 16px; - box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.16); -} - -.asyncapi__channels-toggle .asyncapi__toggle-header-content { - padding: 0; -} -.asyncapi__channels-toggle--expanded .asyncapi__toggle-header { - padding-bottom: 12px; -} - -.asyncapi__channels-header { -} -.asyncapi__channels-header > h2 { - margin: 0 0 24px 0; -} - -.asyncapi__channels-list { - padding: 0; - margin: 0; - list-style-type: none; -} - -.asyncapi__channels-list-item { - margin-bottom: 16px; -} -.asyncapi__channels-list-item:last-child { - margin-bottom: 0; -} - -.asyncapi__channel { - position: relative; - border-radius: 4px; - border: solid 1px rgba(151, 151, 151, 0.26); - /*background-color: #ffffff;*/ -} - -.asyncapi__channel-header { - padding: 12px; -} -.asyncapi__channel-header > h3 { - color: #0b74de; - font-size: 15px; -} - -.asyncapi__channel-header-badges { - margin: 0; - padding: 0; - list-style-type: none; - display: inline-block; -} - -.asyncapi__channel-header-badges > li { - display: inline-block; - margin-right: 6px; -} - -.asyncapi__channel-header-badges-deprecated-badge { - display: inline-block; - margin-right: 6px; -} - -.asyncapi__channel-header-badges-publish-badge { - display: inline-block; - margin-right: 6px; -} - -.asyncapi__channel-header-badges-subscribe-badge { - display: inline-block; - margin-right: 6px; -} - -.asyncapi__channel-header-title { - font-size: 14px; -} - -.asyncapi__channel-operations { -} -.asyncapi__channel-operations .asyncapi__message { - border: none; -} - -.asyncapi__channel-operations-header { - color: #32363a; - /*background-color: #fafafa;*/ - border-top: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__channel-operations-header > h4 { - padding: 12px; - margin: 0; -} - -.asyncapi__channel-operations-header-oneOf { - border-top: solid 1px rgba(151, 151, 151, 0.26); -} - -.asyncapi__channel-operations-list { - margin: 0; - padding: 0; - list-style-type: none; -} -.asyncapi__channel-operations-list .asyncapi__messages { - padding: 16px; -} -.asyncapi__channel-operations-list .asyncapi__messages-list { - margin: 0; - padding: 0; - list-style-type: none; -} -.asyncapi__channel-operations-list .asyncapi__messages-list-item { - margin-bottom: 16px; -} -.asyncapi__channel-operations-list .asyncapi__messages-list-item:last-child { - margin-bottom: 0; -} -.asyncapi__channel-operations-list - .asyncapi__messages-list-item - .asyncapi__message { - position: relative; - border-radius: 4px; - border: solid 1px rgba(151, 151, 151, 0.26); - /*background-color: #ffffff;*/ -} - -.asyncapi__channel-operations-list-item { -} - -.asyncapi__channel-operation-oneOf-subscribe-header { - color: #32363a; - /*background-color: #fafafa;*/ - border-top: solid 1px rgba(151, 151, 151, 0.26); - border-bottom: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__channel-operation-oneOf-subscribe-header > h4 { - padding: 12px; - margin: 0; -} -.asyncapi__channel-operation-oneOf-subscribe-header > h4 > .asyncapi__badge { - margin-right: 6px; -} - -.asyncapi__channel-operation-oneOf-publish-header { - color: #32363a; - /*background-color: #fafafa;*/ - border-top: solid 1px rgba(151, 151, 151, 0.26); - border-bottom: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__channel-operation-oneOf-publish-header > h4 { - padding: 12px; - margin: 0; -} -.asyncapi__channel-operation-oneOf-publish-header > h4 > .asyncapi__badge { - margin-right: 6px; -} - -.asyncapi__channel-operation { -} - -.asyncapi__channel-parameters { -} - -.asyncapi__channel-parameters-header { - color: #32363a; - /*background-color: #fafafa;*/ - border-top: solid 1px rgba(151, 151, 151, 0.26); -} -.asyncapi__channel-parameters-header > h4 { - padding: 12px; - margin: 0; -} - -.asyncapi__channel-parameters-list { - margin: 0; - padding: 0; - list-style-type: none; -} - -.asyncapi__channel-parameters-list-item { -} - -.asyncapi__channel-parameter { - position: relative; -} - -.asyncapi__channel-parameter-header { -} - -.asyncapi__channel-parameter-header-description { -} - -.asyncapi__channel-parameter-schema { -} -.asyncapi__channel-parameter-schema > .asyncapi__schema { - padding: 0; - border: none; -} -.asyncapi__channel-parameter-schema > .asyncapi__schema:before { - content: ''; - position: relative; - border: none; -} - -.asyncapi__error { - /*background-color: #ffffff;*/ - border-left: 6px solid #f44336; - border-radius: 4px; - color: #32363a; - font-family: '72'; - font-size: 13px; - margin-bottom: 24px; - box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.16); -} - -.asyncapi__error-header { - padding: 16px; - box-shadow: inset 0 -1px 0 0 rgba(115, 121, 128, 0.15); - font-weight: bold; - position: relative; - display: flex; - flex-flow: row wrap; - align-items: center; - justify-content: center; -} -.asyncapi__error-header h2 { - flex-grow: 1; - padding: 0; - margin: 0; -} -.asyncapi__error-header > .asyncapi__toggle-header-content { - padding: 0; -} - -.asyncapi__error-body { - font-weight: normal; - position: relative; -} - -.asyncapi__error-body-pre { - margin: 0; - padding: 12px; - background-color: #263238; - white-space: pre-wrap; - word-break: break-word; - color: #fff; - border-bottom-right-radius: 4px; - font-size: 11px; -} - -.asyncapi__error-body-code { - font-family: monospace; - display: block; -} - -.asyncapi__enum { - line-height: 2; - border-style: solid; - border-color: #dae1e7; - border-radius: 0.25rem; - border-width: 1px; - margin-left: 0.25rem; - padding: 0 0.5rem; - color: #f6993f; -} diff --git a/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.tsx b/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.tsx new file mode 100644 index 0000000000..7e01df1460 --- /dev/null +++ b/plugins/api-docs/src/components/GraphQlDefinitionWidget/GraphQlDefinitionWidget.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { Suspense } from 'react'; +import { buildSchema } from 'graphql'; +import { makeStyles } from '@material-ui/core/styles'; +import { Progress } from '@backstage/core'; +import { BackstageTheme } from '@backstage/theme'; + +const GraphiQL = React.lazy(() => import('graphiql')); + +const useStyles = makeStyles(() => ({ + root: { + height: '100%', + display: 'flex', + flexFlow: 'column nowrap', + }, + graphiQlWrapper: { + flex: 1, + '@global': { + '.graphiql-container': { + boxSizing: 'initial', + height: '100%', + minHeight: '600px', + flex: '1 1 auto', + }, + }, + }, +})); + +type Props = { + definition: any; +}; + +export const GraphQlDefinitionWidget = ({ definition }: Props) => { + const classes = useStyles(); + const schema = buildSchema(definition); + + return ( + }> +
+
+ Promise.resolve(null) as any} + schema={schema} + docExplorerOpen + defaultSecondaryEditorOpen={false} + /> +
+
+
+ ); +}; diff --git a/plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts b/plugins/api-docs/src/components/GraphQlDefinitionWidget/index.ts similarity index 89% rename from plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts rename to plugins/api-docs/src/components/GraphQlDefinitionWidget/index.ts index 443bcacc88..b60545de15 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts +++ b/plugins/api-docs/src/components/GraphQlDefinitionWidget/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { WorkflowRunDetailsPage } from './WorkflowRunDetailsPage'; +export { GraphQlDefinitionWidget } from './GraphQlDefinitionWidget'; diff --git a/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinitionWidget.tsx b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinitionWidget.tsx index 1cab3d194d..b96f50166e 100644 --- a/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinitionWidget.tsx @@ -14,13 +14,64 @@ * limitations under the License. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import { makeStyles } from '@material-ui/core/styles'; import SwaggerUI from 'swagger-ui-react'; import 'swagger-ui-react/swagger-ui.css'; -export const OpenApiDefinitionWidget: FC<{ - definition: any; -}> = ({ definition }) => { +// TODO: Schemas + +const useStyles = makeStyles(theme => ({ + root: { + '& .swagger-ui, .info h1, .info h2, .info h3, .info h4, .info h': { + 'font-family': 'inherit', + color: theme.palette.text.primary, + }, + '& .scheme-container': { + 'background-color': theme.palette.background.default, + }, + '& .opblock-tag, .opblock-tag small, table thead tr td, table thead tr th': { + color: theme.palette.text.primary, + 'border-color': theme.palette.divider, + }, + '& section.models, section.models.is-open h4': { + 'border-color': theme.palette.divider, + }, + '& .opblock .opblock-summary-description, .parameter__type, table.headers td, .model-title, .model .property.primitive, section h3': { + color: theme.palette.text.secondary, + }, + '& .opblock .opblock-summary-operation-id, .opblock .opblock-summary-path, .opblock .opblock-summary-path__deprecated, .opblock .opblock-section-header h4, .parameter__name, .response-col_status, .response-col_links, .responses-inner h4, .swagger-ui .responses-inner h5, .opblock-section-header .btn, .tab li, .info li, .info p, .info table, section.models h4, .info .title, table.model tr.description, .property-row': { + color: theme.palette.text.primary, + }, + '& .opblock .opblock-section-header, .model-box, section.models .model-container': { + background: theme.palette.background.default, + }, + '& .prop-format, .parameter__in': { + color: theme.palette.text.disabled, + }, + '& ': { + color: theme.palette.text.primary, + 'border-color': theme.palette.divider, + }, + '& .opblock-description-wrapper p, .opblock-external-docs-wrapper p, .opblock-title_normal p, .response-control-media-type__accept-message, .opblock .opblock-section-header>label, .scheme-container .schemes>label, .info .base-url, .model': { + color: theme.palette.text.hint, + }, + '& .parameter__name.required:after': { + color: theme.palette.warning.dark, + }, + '& .prop-type': { + color: theme.palette.primary.main, + }, + }, +})); + +type Props = { + definition: string; +}; + +export const OpenApiDefinitionWidget = ({ definition }: Props) => { + const classes = useStyles(); + // Due to a bug in the swagger-ui-react component, the component needs // to be created without content first. const [def, setDef] = useState(''); @@ -30,10 +81,8 @@ export const OpenApiDefinitionWidget: FC<{ return () => clearTimeout(timer); }, [definition, setDef]); - // TODO: This looks fine in the light theme, but wrong in dark mode. We need a custom stylesheet for swagger-ui. - // Till then, we add a white background to the swagger-ui to make it usable in dark mode. return ( -
+
); diff --git a/plugins/api-docs/src/components/OpenApiDefinitionWidget/index.ts b/plugins/api-docs/src/components/OpenApiDefinitionWidget/index.ts new file mode 100644 index 0000000000..b2a0f0b86d --- /dev/null +++ b/plugins/api-docs/src/components/OpenApiDefinitionWidget/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget'; diff --git a/plugins/api-docs/src/components/PlainApiDefinitionWidget/PlainApiDefinitionWidget.tsx b/plugins/api-docs/src/components/PlainApiDefinitionWidget/PlainApiDefinitionWidget.tsx index 4ceeed06e1..0b2ebb4ca4 100644 --- a/plugins/api-docs/src/components/PlainApiDefinitionWidget/PlainApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/PlainApiDefinitionWidget/PlainApiDefinitionWidget.tsx @@ -15,10 +15,15 @@ */ import { CodeSnippet } from '@backstage/core'; -import React, { FC } from 'react'; +import React from 'react'; -export const PlainApiDefinitionWidget: FC<{ +type Props = { definition: any; -}> = ({ definition }) => { - return ; + language: string; +}; + +export const PlainApiDefinitionWidget = ({ definition, language }: Props) => { + return ( + + ); }; diff --git a/plugins/api-docs/src/components/PlainApiDefinitionWidget/index.ts b/plugins/api-docs/src/components/PlainApiDefinitionWidget/index.ts new file mode 100644 index 0000000000..c9d18d1ae8 --- /dev/null +++ b/plugins/api-docs/src/components/PlainApiDefinitionWidget/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget'; diff --git a/plugins/api-docs/src/components/index.ts b/plugins/api-docs/src/components/index.ts new file mode 100644 index 0000000000..55b9517f9c --- /dev/null +++ b/plugins/api-docs/src/components/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ApiDefinitionCard } from './ApiDefinitionCard'; +export { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget'; +export { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget'; +export { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget'; +export { useComponentApiNames } from './useComponentApiNames'; +export { useComponentApiEntities } from './useComponentApiEntities'; diff --git a/plugins/api-docs/src/components/useComponentApiEntities.ts b/plugins/api-docs/src/components/useComponentApiEntities.ts new file mode 100644 index 0000000000..11b5de988f --- /dev/null +++ b/plugins/api-docs/src/components/useComponentApiEntities.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useAsyncRetry } from 'react-use'; +import { errorApiRef, useApi } from '@backstage/core'; +import { ApiEntity, ComponentEntity } from '@backstage/catalog-model'; +import { catalogApiRef } from '@backstage/plugin-catalog'; +import { useComponentApiNames } from './useComponentApiNames'; + +export function useComponentApiEntities({ + entity, +}: { + entity: ComponentEntity; +}): { + loading: boolean; + apiEntities?: Map; + error?: Error; + retry: () => void; +} { + const catalogApi = useApi(catalogApiRef); + const errorApi = useApi(errorApiRef); + + const apiNames = useComponentApiNames(entity); + + const { loading, value: apiEntities, retry, error } = useAsyncRetry< + Map + >(async () => { + const resultMap = new Map(); + + await Promise.all( + apiNames.map(async name => { + try { + const api = (await catalogApi.getEntityByName({ + kind: 'API', + name, + })) as ApiEntity | undefined; + + if (api) { + resultMap.set(api.metadata.name, api); + } + } catch (e) { + errorApi.post(e); + } + }), + ); + + return resultMap; + }, [catalogApi, entity]); + + return { + apiEntities, + loading, + error, + retry, + }; +} diff --git a/plugins/api-docs/src/components/useComponentApiNames.ts b/plugins/api-docs/src/components/useComponentApiNames.ts new file mode 100644 index 0000000000..0eabe2b6c7 --- /dev/null +++ b/plugins/api-docs/src/components/useComponentApiNames.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentEntity } from '@backstage/catalog-model'; + +export const useComponentApiNames = (entity: ComponentEntity) => { + return (entity.spec?.implementsApis as string[]) || []; +}; diff --git a/plugins/api-docs/src/index.ts b/plugins/api-docs/src/index.ts index c82a2c263b..958355f063 100644 --- a/plugins/api-docs/src/index.ts +++ b/plugins/api-docs/src/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { ApiDefinitionCard } from './components/ApiDefinitionCard/ApiDefinitionCard'; +export { Router } from './catalog'; export { plugin } from './plugin'; diff --git a/plugins/api-docs/src/plugin.ts b/plugins/api-docs/src/plugin.ts index db58538b67..bd9c968d5b 100644 --- a/plugins/api-docs/src/plugin.ts +++ b/plugins/api-docs/src/plugin.ts @@ -15,14 +15,14 @@ */ import { createPlugin } from '@backstage/core'; -import { ApiCatalogPage } from './components/ApiCatalogPage/ApiCatalogPage'; +import { ApiExplorerPage } from './components/ApiExplorerPage/ApiExplorerPage'; import { ApiEntityPage } from './components/ApiEntityPage/ApiEntityPage'; import { entityRoute, rootRoute } from './routes'; export const plugin = createPlugin({ id: 'api-docs', register({ router }) { - router.addRoute(rootRoute, ApiCatalogPage); + router.addRoute(rootRoute, ApiExplorerPage); router.addRoute(entityRoute, ApiEntityPage); }, }); diff --git a/plugins/api-docs/src/routes.ts b/plugins/api-docs/src/routes.ts index 48c530d4b5..eea911dd62 100644 --- a/plugins/api-docs/src/routes.ts +++ b/plugins/api-docs/src/routes.ts @@ -28,3 +28,8 @@ export const entityRoute = createRouteRef({ path: '/api-docs/:optionalNamespaceAndName/', title: 'API', }); +export const catalogRoute = createRouteRef({ + icon: NoIcon, + path: '', + title: 'API', +}); diff --git a/plugins/app-backend/.eslintrc.js b/plugins/app-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/app-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/app-backend/README.md b/plugins/app-backend/README.md new file mode 100644 index 0000000000..2dc302accb --- /dev/null +++ b/plugins/app-backend/README.md @@ -0,0 +1,32 @@ +# App backend plugin + +This backend plugin can be installed to serve static content of a Backstage app. + +## Installation + +Add both this package and your local frontend app package as dependencies to your backend, for example + +```bash +yarn add @backstage/plugin-app-backend example-app +``` + +By adding the app package as a dependency we ensure that it is built as part of the backend, and that it can be resolved at runtime. + +Now add the plugin router to your app, creating it for example like this: + +```ts +const router = await createRouter({ + logger, + appPackageName: 'example-app', +}); +``` + +And registering it like this: + +```ts +createServiceBuilder(module) + ... + .addRouter('', router); +``` + +Be sure to register the app router last, as it serves content for HTML5-mode navigation, i.e. falling back to serving `index.html` for any route that can't be found. diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json new file mode 100644 index 0000000000..f4080abc42 --- /dev/null +++ b/plugins/app-backend/package.json @@ -0,0 +1,42 @@ +{ + "name": "@backstage/plugin-app-backend", + "version": "0.1.1-alpha.21", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.1.1-alpha.21", + "@backstage/config-loader": "^0.1.1-alpha.21", + "@types/express": "^4.17.6", + "express": "^4.17.1", + "express-promise-router": "^3.0.3", + "fs-extra": "^9.0.0", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.21", + "@types/supertest": "^2.0.8", + "msw": "^0.19.5", + "supertest": "^4.0.2" + }, + "files": [ + "dist", + "static" + ] +} diff --git a/plugins/github-actions/src/components/Widget/index.ts b/plugins/app-backend/src/index.ts similarity index 94% rename from plugins/github-actions/src/components/Widget/index.ts rename to plugins/app-backend/src/index.ts index 2b34671ab5..7612c392a2 100644 --- a/plugins/github-actions/src/components/Widget/index.ts +++ b/plugins/app-backend/src/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { Widget } from './Widget'; + +export * from './service/router'; diff --git a/plugins/app-backend/src/lib/config.test.ts b/plugins/app-backend/src/lib/config.test.ts new file mode 100644 index 0000000000..16c5600b1d --- /dev/null +++ b/plugins/app-backend/src/lib/config.test.ts @@ -0,0 +1,136 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import { getVoidLogger } from '@backstage/backend-common'; +import { injectEnvConfig } from './config'; + +jest.mock('fs-extra'); + +const fsMock = fs as jest.Mocked; +const readFileMock = (fsMock.readFile as unknown) as jest.MockedFunction< + (name: string) => Promise +>; + +const MOCK_DIR = 'mock-dir'; + +const baseOptions = { + env: {}, + staticDir: MOCK_DIR, + logger: getVoidLogger(), +}; + +describe('injectEnvConfig', () => { + beforeEach(() => { + fsMock.readdir.mockResolvedValue(['main.js']); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should not inject without config', async () => { + await injectEnvConfig(baseOptions); + expect(fsMock.readdir).toHaveBeenCalledTimes(0); + expect(fsMock.readFile).toHaveBeenCalledTimes(0); + expect(fsMock.writeFile).toHaveBeenCalledTimes(0); + }); + + it('should find the correct file to inject', async () => { + fsMock.readdir.mockResolvedValue([ + 'before.js', + 'not-js.txt', + 'main.js', + 'after.js', + ]); + readFileMock.mockImplementation(async (file: string) => { + if (file.endsWith('main.js')) { + return '"__APP_INJECTED_RUNTIME_CONFIG__"'; + } + return 'NO_PLACEHOLDER_HERE'; + }); + + await injectEnvConfig({ ...baseOptions, env: { APP_CONFIG_x: '0' } }); + expect(fsMock.readFile).toHaveBeenCalledTimes(2); + expect(fsMock.readFile).toHaveBeenNthCalledWith( + 1, + resolvePath(MOCK_DIR, 'before.js'), + 'utf8', + ); + expect(fsMock.readFile).toHaveBeenNthCalledWith( + 2, + resolvePath(MOCK_DIR, 'main.js'), + 'utf8', + ); + + expect(fsMock.writeFile).toHaveBeenCalledTimes(1); + expect(fsMock.writeFile).toHaveBeenCalledWith( + resolvePath(MOCK_DIR, 'main.js'), + '/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/', + 'utf8', + ); + + // eslint-disable-next-line no-eval + expect(JSON.parse(eval(fsMock.writeFile.mock.calls[0][1]))).toEqual({ + x: 0, + }); + }); + + it('should re-inject config', async () => { + fsMock.readdir.mockResolvedValue(['main.js']); + readFileMock.mockResolvedValue( + 'JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__")', + ); + + await injectEnvConfig({ + ...baseOptions, + env: { + APP_CONFIG_x: '0', + }, + }); + + expect(fsMock.writeFile).toHaveBeenCalledTimes(1); + expect(fsMock.writeFile).toHaveBeenCalledWith( + resolvePath(MOCK_DIR, 'main.js'), + 'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/)', + 'utf8', + ); + + // eslint-disable-next-line no-eval + expect(eval(fsMock.writeFile.mock.calls[0][1])).toEqual({ x: 0 }); + + readFileMock.mockResolvedValue(fsMock.writeFile.mock.calls[0][1]); + + await injectEnvConfig({ + ...baseOptions, + env: { + APP_CONFIG_x: '1', + APP_CONFIG_y: '2', + }, + }); + + expect(fsMock.writeFile).toHaveBeenCalledTimes(2); + expect(fsMock.writeFile).toHaveBeenLastCalledWith( + resolvePath(MOCK_DIR, 'main.js'), + 'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":1,\\"y\\":2}"/*__INJECTED_END__*/)', + 'utf8', + ); + + // eslint-disable-next-line no-eval + expect(eval(fsMock.writeFile.mock.calls[1][1])).toEqual({ x: 1, y: 2 }); + }); +}); diff --git a/plugins/app-backend/src/lib/config.ts b/plugins/app-backend/src/lib/config.ts new file mode 100644 index 0000000000..0ed56ef722 --- /dev/null +++ b/plugins/app-backend/src/lib/config.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import { readEnvConfig } from '@backstage/config-loader'; +import { Logger } from 'winston'; + +type Options = { + // Environment to read config from + env: { [name: string]: string | undefined }; + // Directory of the static JS files to search for file to inject + staticDir: string; + logger: Logger; +}; + +/** + * Injects config from APP_CONFIG_ env vars, replacing existing + * injected config if it has already been injected. + */ +export async function injectEnvConfig(options: Options) { + const { env, staticDir, logger } = options; + + const envConfig = readEnvConfig(env); + if (envConfig.length === 0) { + return; + } + + const files = await fs.readdir(staticDir); + const jsFiles = files.filter(file => file.endsWith('.js')); + + const [{ data }] = envConfig; + const escapedData = JSON.stringify(data).replace(/("|'|\\)/g, '\\$1'); + const injected = `/*__APP_INJECTED_CONFIG_MARKER__*/"${escapedData}"/*__INJECTED_END__*/`; + + for (const jsFile of jsFiles) { + const path = resolvePath(staticDir, jsFile); + + const content = await fs.readFile(path, 'utf8'); + if (content.includes('__APP_INJECTED_RUNTIME_CONFIG__')) { + logger.info(`Injecting env config into ${jsFile}`); + + const newContent = content.replace( + '"__APP_INJECTED_RUNTIME_CONFIG__"', + injected, + ); + await fs.writeFile(path, newContent, 'utf8'); + return; + } else if (content.includes('__APP_INJECTED_CONFIG_MARKER__')) { + logger.info(`Replacing injected env config in ${jsFile}`); + + const newContent = content.replace( + /\/\*__APP_INJECTED_CONFIG_MARKER__\*\/.*\/\*__INJECTED_END__\*\//, + injected, + ); + await fs.writeFile(path, newContent, 'utf8'); + return; + } + } + logger.info('Env config not injected'); +} diff --git a/plugins/app-backend/src/run.ts b/plugins/app-backend/src/run.ts new file mode 100644 index 0000000000..b96989e4b8 --- /dev/null +++ b/plugins/app-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/app-backend/src/service/__fixtures__/app-dir/.gitignore b/plugins/app-backend/src/service/__fixtures__/app-dir/.gitignore new file mode 100644 index 0000000000..cbdb9611d1 --- /dev/null +++ b/plugins/app-backend/src/service/__fixtures__/app-dir/.gitignore @@ -0,0 +1 @@ +!dist diff --git a/plugins/app-backend/src/service/__fixtures__/app-dir/dist/index.html b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/index.html new file mode 100644 index 0000000000..2085b25414 --- /dev/null +++ b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/index.html @@ -0,0 +1 @@ +this is index.html diff --git a/plugins/app-backend/src/service/__fixtures__/app-dir/dist/other.html b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/other.html new file mode 100644 index 0000000000..a4ea9022ac --- /dev/null +++ b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/other.html @@ -0,0 +1 @@ +this is other.html diff --git a/plugins/app-backend/src/service/__fixtures__/app-dir/dist/static/main.txt b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/static/main.txt new file mode 100644 index 0000000000..ec835599f6 --- /dev/null +++ b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/static/main.txt @@ -0,0 +1 @@ +this is main.txt diff --git a/plugins/app-backend/src/service/router.test.ts b/plugins/app-backend/src/service/router.test.ts new file mode 100644 index 0000000000..417fdc43b6 --- /dev/null +++ b/plugins/app-backend/src/service/router.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve as resolvePath } from 'path'; +import { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import request from 'supertest'; + +import { createRouter } from './router'; + +jest.mock('../lib/config', () => ({ injectEnvConfig: jest.fn() })); + +global.__non_webpack_require__ = { + resolve: () => resolvePath(__dirname, '__fixtures__/app-dir/package.json'), +}; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + appPackageName: 'example-app', + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns index.html', async () => { + const response = await request(app).get('/index.html'); + + expect(response.status).toBe(200); + expect(response.text.trim()).toBe('this is index.html'); + }); + + it('returns other.html', async () => { + const response = await request(app).get('/other.html'); + + expect(response.status).toBe(200); + expect(response.text.trim()).toBe('this is other.html'); + }); + + it('returns index.html if missing', async () => { + const response = await request(app).get('/missing.html'); + + expect(response.status).toBe(200); + expect(response.text.trim()).toBe('this is index.html'); + }); +}); + +describe('createRouter with static fallback handler', () => { + it('uses static fallback handler', async () => { + const staticFallbackHandler = Router(); + + staticFallbackHandler.get('/test.txt', (_req, res) => { + res.end('this is test.txt'); + }); + + const router = await createRouter({ + logger: getVoidLogger(), + appPackageName: 'example-app', + staticFallbackHandler, + }); + + const app = express().use(router); + + const response1 = await request(app).get('/static/main.txt'); + expect(response1.status).toBe(200); + expect(response1.text.trim()).toBe('this is main.txt'); + + const response2 = await request(app).get('/static/test.txt'); + expect(response2.status).toBe(200); + expect(response2.text.trim()).toBe('this is test.txt'); + + const response3 = await request(app).get('/static/missing.txt'); + expect(response3.status).toBe(404); + }); +}); diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts new file mode 100644 index 0000000000..c9a06e4dda --- /dev/null +++ b/plugins/app-backend/src/service/router.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve as resolvePath } from 'path'; +import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { injectEnvConfig } from '../lib/config'; + +export interface RouterOptions { + logger: Logger; + appPackageName: string; + staticFallbackHandler?: express.Handler; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const appDistDir = resolvePackagePath(options.appPackageName, 'dist'); + options.logger.info(`Serving static app content from ${appDistDir}`); + + await injectEnvConfig({ + env: process.env, + logger: options.logger, + staticDir: resolvePath(appDistDir, 'static'), + }); + + const router = Router(); + + // Use a separate router for static content so that a fallback can be provided by backend + const staticRouter = Router(); + staticRouter.use(express.static(resolvePath(appDistDir, 'static'))); + if (options.staticFallbackHandler) { + staticRouter.use(options.staticFallbackHandler); + } + staticRouter.use(notFoundHandler()); + + router.use('/static', staticRouter); + router.use(express.static(appDistDir)); + router.get('/*', (_req, res) => { + res.sendFile(resolvePath(appDistDir, 'index.html')); + }); + + return router; +} diff --git a/plugins/app-backend/src/service/standaloneServer.ts b/plugins/app-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..8abf3b81f2 --- /dev/null +++ b/plugins/app-backend/src/service/standaloneServer.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createServiceBuilder } from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'app-backend' }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + appPackageName: 'example-app', + }); + + const service = createServiceBuilder(module).addRouter('', router); + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/jenkins/src/components/Layout/index.ts b/plugins/app-backend/src/setupTests.ts similarity index 95% rename from plugins/jenkins/src/components/Layout/index.ts rename to plugins/app-backend/src/setupTests.ts index 236fc98851..ba33cf996b 100644 --- a/plugins/jenkins/src/components/Layout/index.ts +++ b/plugins/app-backend/src/setupTests.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './Layout'; + +export {}; diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index b25c886075..9af90df3f2 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -66,6 +66,49 @@ export AUTH_OKTA_CLIENT_ID=x export AUTH_OKTA_CLIENT_SECRET=x ``` +### Auth0 + +```bash +export AUTH_AUTH0_DOMAIN=x +export AUTH_AUTH0_CLIENT_ID=x +export AUTH_AUTH0_CLIENT_SECRET=x +``` + +### Microsoft + +#### Creating an Azure AD App Registration + +An Azure AD App Registration is required to be able to sign in using Azure AD and the Microsoft Graph API. +Click [here](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) to create a new one. + +- Click on the `New Registration` button. +- Give the app a name. e.g. `backstage-dev` +- Select `Accounts in this organizational directory only` under supported account types. +- Enter the callback URL for your backstage backend instance: + - For local development, this is likely `http://localhost:7000/auth/microsoft/handler/frame` + - For non-local deployments, this will be `https://{APP_FQDN}:{APP_BACKEND_PORT}/auth/microsoft/handler/frame` +- Click `Register`. + +We also need to generate a client secret so Backstage can authenticate as this app. + +- Click on the `Certificates & secrets` menu item. +- Under `Client secrets`, click on `New client secret`. +- Add a description for the new secret. e.g. `auth-backend-plugin` +- Select an expiry time; `1 Year`, `2 Years` or `Never`. +- Click `Add`. + +The secret value will then be displayed on the screen. **You will not be able to retrieve it again after leaving the page**. + +#### Starting the Auth Backend + +```bash +cd packages/backend +export AUTH_MICROSOFT_CLIENT_ID=x +export AUTH_MICROSOFT_CLIENT_SECRET=x +export AUTH_MICROSOFT_TENANT_ID=x +yarn start +``` + ### SAML To try out SAML, you can use the mock identity provider: @@ -80,4 +123,4 @@ To try out SAML, you can use the mock identity provider: ## Links -- (The Backstage homepage)[https://backstage.io] +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 65111e599c..2336adb860 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,16 +20,16 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.18", - "@backstage/config": "^0.1.1-alpha.18", + "@backstage/backend-common": "^0.1.1-alpha.21", + "@backstage/config": "^0.1.1-alpha.21", "@types/express": "^4.17.6", - "body-parser": "^1.19.0", "compression": "^1.7.4", "cookie-parser": "^1.4.5", "cors": "^2.8.5", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", + "got": "^11.5.2", "helmet": "^4.0.0", "jose": "^1.27.1", "jwt-decode": "2.2.0", @@ -40,6 +40,7 @@ "passport-github2": "^0.1.12", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", + "passport-microsoft": "^0.1.0", "passport-oauth2": "^1.5.0", "passport-okta-oauth": "^0.0.1", "passport-saml": "^1.3.3", @@ -48,13 +49,14 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/jwt-decode": "2.2.1", "@types/passport": "^1.0.3", "@types/passport-github2": "^1.2.4", "@types/passport-google-oauth20": "^2.0.3", + "@types/passport-microsoft": "^0.0.0", "@types/passport-saml": "^1.1.2", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/auth-backend/src/identity/DatabaseKeyStore.ts b/plugins/auth-backend/src/identity/DatabaseKeyStore.ts index c175cd6e12..c24cac55be 100644 --- a/plugins/auth-backend/src/identity/DatabaseKeyStore.ts +++ b/plugins/auth-backend/src/identity/DatabaseKeyStore.ts @@ -15,13 +15,13 @@ */ import Knex from 'knex'; -import path from 'path'; import { utc } from 'moment'; +import { resolvePackagePath } from '@backstage/backend-common'; import { AnyJWK, KeyStore, StoredKey } from './types'; -const migrationsDir = path.resolve( - require.resolve('@backstage/plugin-auth-backend/package.json'), - '../migrations', +const migrationsDir = resolvePackagePath( + '@backstage/plugin-auth-backend', + 'migrations', ); const TABLE = 'signing_keys'; diff --git a/plugins/auth-backend/src/lib/EnvironmentHandler.ts b/plugins/auth-backend/src/lib/EnvironmentHandler.ts deleted file mode 100644 index fb39ed86b8..0000000000 --- a/plugins/auth-backend/src/lib/EnvironmentHandler.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express from 'express'; -import { - AuthProviderRouteHandlers, - EnvironmentIdentifierFn, -} from '../providers/types'; - -export type EnvironmentHandlers = { - [key: string]: AuthProviderRouteHandlers; -}; - -export class EnvironmentHandler implements AuthProviderRouteHandlers { - constructor( - private readonly providerId: string, - private readonly providers: EnvironmentHandlers, - private readonly envIdentifier: EnvironmentIdentifierFn, - ) {} - - private getProviderForEnv( - req: express.Request, - res: express.Response, - ): AuthProviderRouteHandlers | undefined { - const env: string | undefined = this.envIdentifier(req); - - if (env && this.providers.hasOwnProperty(env)) { - return this.providers[env]; - } - - res.status(404).send( - `Missing configuration. -
-
-For this flow to work you need to supply a valid configuration for the "${env}" environment of the "${this.providerId}" provider.`, - ); - return undefined; - } - - async start(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.start(req, res); - } - - async frameHandler( - req: express.Request, - res: express.Response, - ): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.frameHandler(req, res); - } - - async refresh(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.refresh?.(req, res); - } - - async logout(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.logout?.(req, res); - } -} diff --git a/plugins/auth-backend/src/lib/flow/authFlowHelpers.test.ts b/plugins/auth-backend/src/lib/flow/authFlowHelpers.test.ts new file mode 100644 index 0000000000..f42e4c4270 --- /dev/null +++ b/plugins/auth-backend/src/lib/flow/authFlowHelpers.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers'; +import { WebMessageResponse } from './types'; + +describe('oauth helpers', () => { + describe('postMessageResponse', () => { + const appOrigin = 'http://localhost:3000'; + it('should post a message back with payload success', () => { + const mockResponse = ({ + end: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + const data: WebMessageResponse = { + type: 'authorization_response', + response: { + providerInfo: { + accessToken: 'ACCESS_TOKEN', + idToken: 'ID_TOKEN', + expiresInSeconds: 10, + scope: 'email', + }, + profile: { + email: 'foo@bar.com', + }, + backstageIdentity: { + id: 'a', + idToken: 'a.b.c', + }, + }, + }; + const jsonData = JSON.stringify(data); + const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); + + postMessageResponse(mockResponse, appOrigin, data); + expect(mockResponse.setHeader).toBeCalledTimes(3); + expect(mockResponse.end).toBeCalledTimes(1); + expect(mockResponse.end).toBeCalledWith( + expect.stringContaining(base64Data), + ); + }); + + it('should post a message back with payload error', () => { + const mockResponse = ({ + end: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + const data: WebMessageResponse = { + type: 'authorization_response', + error: new Error('Unknown error occured'), + }; + const jsonData = JSON.stringify(data); + const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); + + postMessageResponse(mockResponse, appOrigin, data); + expect(mockResponse.setHeader).toBeCalledTimes(3); + expect(mockResponse.end).toBeCalledTimes(1); + expect(mockResponse.end).toBeCalledWith( + expect.stringContaining(base64Data), + ); + }); + }); + + describe('ensuresXRequestedWith', () => { + it('should return false if no header present', () => { + const mockRequest = ({ + header: () => jest.fn(), + } as unknown) as express.Request; + expect(ensuresXRequestedWith(mockRequest)).toBe(false); + }); + + it('should return false if header present with incorrect value', () => { + const mockRequest = ({ + header: () => 'INVALID', + } as unknown) as express.Request; + expect(ensuresXRequestedWith(mockRequest)).toBe(false); + }); + + it('should return true if header present with correct value', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + } as unknown) as express.Request; + expect(ensuresXRequestedWith(mockRequest)).toBe(true); + }); + }); +}); diff --git a/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts b/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts new file mode 100644 index 0000000000..63d7c28b50 --- /dev/null +++ b/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import crypto from 'crypto'; +import { WebMessageResponse } from './types'; + +export const postMessageResponse = ( + res: express.Response, + appOrigin: string, + response: WebMessageResponse, +) => { + const jsonData = JSON.stringify(response); + const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); + + res.setHeader('Content-Type', 'text/html'); + res.setHeader('X-Frame-Options', 'sameorigin'); + + // TODO: Make target app origin configurable globally + const script = ` + (window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}') + window.close() + `; + const hash = crypto.createHash('sha256').update(script).digest('base64'); + res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`); + + res.end(` + + + + + + `); +}; + +export const ensuresXRequestedWith = (req: express.Request) => { + const requiredHeader = req.header('X-Requested-With'); + + if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') { + return false; + } + return true; +}; diff --git a/plugins/auth-backend/src/lib/flow/index.ts b/plugins/auth-backend/src/lib/flow/index.ts new file mode 100644 index 0000000000..a5f2f7a3ac --- /dev/null +++ b/plugins/auth-backend/src/lib/flow/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers'; diff --git a/plugins/circleci/src/state/types.ts b/plugins/auth-backend/src/lib/flow/types.ts similarity index 59% rename from plugins/circleci/src/state/types.ts rename to plugins/auth-backend/src/lib/flow/types.ts index 41b3577082..98bb551c2c 100644 --- a/plugins/circleci/src/state/types.ts +++ b/plugins/auth-backend/src/lib/flow/types.ts @@ -14,23 +14,18 @@ * limitations under the License. */ -export type Settings = { owner: string; repo: string; token: string }; -export type SettingsState = Settings & { - showSettings: boolean; -}; +import { AuthResponse } from '../../providers/types'; -export type State = SettingsState; - -type SettingsAction = +/** + * Payload sent as a post message after the auth request is complete. + * If successful then has a valid payload with Auth information else contains an error. + */ +export type WebMessageResponse = | { - type: 'setCredentials'; - payload: { - repo: string; - owner: string; - token: string; - }; + type: 'authorization_response'; + response: AuthResponse; } - | { type: 'showSettings' } - | { type: 'hideSettings' }; - -export type Action = SettingsAction; + | { + type: 'authorization_response'; + error: Error; + }; diff --git a/plugins/auth-backend/src/lib/OAuthProvider.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts similarity index 55% rename from plugins/auth-backend/src/lib/OAuthProvider.test.ts rename to plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index 0778b875d0..d2b31213f2 100644 --- a/plugins/auth-backend/src/lib/OAuthProvider.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -15,16 +15,9 @@ */ import express from 'express'; -import { - ensuresXRequestedWith, - postMessageResponse, - THOUSAND_DAYS_MS, - TEN_MINUTES_MS, - verifyNonce, - encodeState, - OAuthProvider, -} from './OAuthProvider'; -import { WebMessageResponse, OAuthProviderHandlers } from '../providers/types'; +import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter'; +import { encodeState } from './helpers'; +import { OAuthHandlers } from './types'; const mockResponseData = { providerInfo: { @@ -41,149 +34,8 @@ const mockResponseData = { }, }; -describe('OAuthProvider Utils', () => { - describe('verifyNonce', () => { - it('should throw error if cookie nonce missing', () => { - const state = { nonce: 'NONCE', env: 'development' }; - const mockRequest = ({ - cookies: {}, - query: { - state: encodeState(state), - }, - } as unknown) as express.Request; - expect(() => { - verifyNonce(mockRequest, 'providera'); - }).toThrowError('Auth response is missing cookie nonce'); - }); - - it('should throw error if state nonce missing', () => { - const mockRequest = ({ - cookies: { - 'providera-nonce': 'NONCE', - }, - query: {}, - } as unknown) as express.Request; - expect(() => { - verifyNonce(mockRequest, 'providera'); - }).toThrowError('Invalid state passed via request'); - }); - - it('should throw error if nonce mismatch', () => { - const state = { nonce: 'NONCEB', env: 'development' }; - const mockRequest = ({ - cookies: { - 'providera-nonce': 'NONCEA', - }, - query: { - state: encodeState(state), - }, - } as unknown) as express.Request; - expect(() => { - verifyNonce(mockRequest, 'providera'); - }).toThrowError('Invalid nonce'); - }); - - it('should not throw any error if nonce matches', () => { - const state = { nonce: 'NONCE', env: 'development' }; - const mockRequest = ({ - cookies: { - 'providera-nonce': 'NONCE', - }, - query: { - state: encodeState(state), - }, - } as unknown) as express.Request; - expect(() => { - verifyNonce(mockRequest, 'providera'); - }).not.toThrow(); - }); - }); - - describe('postMessageResponse', () => { - const appOrigin = 'http://localhost:3000'; - it('should post a message back with payload success', () => { - const mockResponse = ({ - end: jest.fn().mockReturnThis(), - setHeader: jest.fn().mockReturnThis(), - } as unknown) as express.Response; - - const data: WebMessageResponse = { - type: 'authorization_response', - response: { - providerInfo: { - accessToken: 'ACCESS_TOKEN', - idToken: 'ID_TOKEN', - expiresInSeconds: 10, - scope: 'email', - }, - profile: { - email: 'foo@bar.com', - }, - backstageIdentity: { - id: 'a', - idToken: 'a.b.c', - }, - }, - }; - const jsonData = JSON.stringify(data); - const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); - - postMessageResponse(mockResponse, appOrigin, data); - expect(mockResponse.setHeader).toBeCalledTimes(3); - expect(mockResponse.end).toBeCalledTimes(1); - expect(mockResponse.end).toBeCalledWith( - expect.stringContaining(base64Data), - ); - }); - - it('should post a message back with payload error', () => { - const mockResponse = ({ - end: jest.fn().mockReturnThis(), - setHeader: jest.fn().mockReturnThis(), - } as unknown) as express.Response; - - const data: WebMessageResponse = { - type: 'authorization_response', - error: new Error('Unknown error occured'), - }; - const jsonData = JSON.stringify(data); - const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); - - postMessageResponse(mockResponse, appOrigin, data); - expect(mockResponse.setHeader).toBeCalledTimes(3); - expect(mockResponse.end).toBeCalledTimes(1); - expect(mockResponse.end).toBeCalledWith( - expect.stringContaining(base64Data), - ); - }); - }); - - describe('ensuresXRequestedWith', () => { - it('should return false if no header present', () => { - const mockRequest = ({ - header: () => jest.fn(), - } as unknown) as express.Request; - expect(ensuresXRequestedWith(mockRequest)).toBe(false); - }); - - it('should return false if header present with incorrect value', () => { - const mockRequest = ({ - header: () => 'INVALID', - } as unknown) as express.Request; - expect(ensuresXRequestedWith(mockRequest)).toBe(false); - }); - - it('should return true if header present with correct value', () => { - const mockRequest = ({ - header: () => 'XMLHttpRequest', - } as unknown) as express.Request; - expect(ensuresXRequestedWith(mockRequest)).toBe(true); - }); - }); -}); - -describe('OAuthProvider', () => { - class MyAuthProvider implements OAuthProviderHandlers { +describe('OAuthAdapter', () => { + class MyAuthProvider implements OAuthHandlers { async start() { return { url: '/url', @@ -205,8 +57,9 @@ describe('OAuthProvider', () => { providerId: 'test-provider', secure: false, disableRefresh: true, - baseUrl: 'http://localhost:7000/auth', appOrigin: 'http://localhost:3000', + cookieDomain: 'localhost', + cookiePath: '/auth/test-provider', tokenIssuer: { issueToken: async () => 'my-id-token', listPublicKeys: async () => ({ keys: [] }), @@ -214,7 +67,7 @@ describe('OAuthProvider', () => { }; it('sets the correct headers in start', async () => { - const oauthProvider = new OAuthProvider( + const oauthProvider = new OAuthAdapter( providerInstance, oAuthProviderOptions, ); @@ -249,7 +102,7 @@ describe('OAuthProvider', () => { }); it('sets the refresh cookie if refresh is enabled', async () => { - const oauthProvider = new OAuthProvider(providerInstance, { + const oauthProvider = new OAuthAdapter(providerInstance, { ...oAuthProviderOptions, disableRefresh: false, }); @@ -283,7 +136,7 @@ describe('OAuthProvider', () => { }); it('does not set the refresh cookie if refresh is disabled', async () => { - const oauthProvider = new OAuthProvider(providerInstance, { + const oauthProvider = new OAuthAdapter(providerInstance, { ...oAuthProviderOptions, disableRefresh: true, }); @@ -308,7 +161,7 @@ describe('OAuthProvider', () => { }); it('removes refresh cookie when logging out', async () => { - const oauthProvider = new OAuthProvider(providerInstance, { + const oauthProvider = new OAuthAdapter(providerInstance, { ...oAuthProviderOptions, disableRefresh: false, }); @@ -333,7 +186,7 @@ describe('OAuthProvider', () => { it('gets new access-token when refreshing', async () => { oAuthProviderOptions.disableRefresh = false; - const oauthProvider = new OAuthProvider(providerInstance, { + const oauthProvider = new OAuthAdapter(providerInstance, { ...oAuthProviderOptions, disableRefresh: false, }); @@ -362,7 +215,7 @@ describe('OAuthProvider', () => { }); it('handles refresh without capabilities', async () => { - const oauthProvider = new OAuthProvider(providerInstance, { + const oauthProvider = new OAuthAdapter(providerInstance, { ...oAuthProviderOptions, disableRefresh: true, }); diff --git a/plugins/auth-backend/src/lib/OAuthProvider.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts similarity index 60% rename from plugins/auth-backend/src/lib/OAuthProvider.ts rename to plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index f2b8c8e09c..62cb8e444d 100644 --- a/plugins/auth-backend/src/lib/OAuthProvider.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -19,13 +19,14 @@ import crypto from 'crypto'; import { URL } from 'url'; import { AuthProviderRouteHandlers, - OAuthProviderHandlers, - WebMessageResponse, BackstageIdentity, - OAuthState, -} from '../providers/types'; + AuthProviderConfig, +} from '../../providers/types'; import { InputError } from '@backstage/backend-common'; -import { TokenIssuer } from '../identity'; +import { TokenIssuer } from '../../identity'; +import { verifyNonce } from './helpers'; +import { postMessageResponse, ensuresXRequestedWith } from '../flow'; +import { OAuthHandlers, OAuthStartRequest, OAuthRefreshRequest } from './types'; export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000; export const TEN_MINUTES_MS = 600 * 1000; @@ -35,102 +36,38 @@ export type Options = { secure: boolean; disableRefresh?: boolean; persistScopes?: boolean; - baseUrl: string; + cookieDomain: string; + cookiePath: string; appOrigin: string; tokenIssuer: TokenIssuer; }; -const readState = (stateString: string): OAuthState => { - const state = Object.fromEntries( - new URLSearchParams(decodeURIComponent(stateString)), - ); - if ( - !state.nonce || - !state.env || - state.nonce?.length === 0 || - state.env?.length === 0 - ) { - throw Error(`Invalid state passed via request`); +export class OAuthAdapter implements AuthProviderRouteHandlers { + static fromConfig( + config: AuthProviderConfig, + handlers: OAuthHandlers, + options: Pick< + Options, + 'providerId' | 'persistScopes' | 'disableRefresh' | 'tokenIssuer' + >, + ): OAuthAdapter { + const { origin: appOrigin } = new URL(config.appUrl); + const secure = config.baseUrl.startsWith('https://'); + const url = new URL(config.baseUrl); + const cookiePath = `${url.pathname}/${options.providerId}`; + return new OAuthAdapter(handlers, { + ...options, + appOrigin, + cookieDomain: url.hostname, + cookiePath, + secure, + }); } - return { - nonce: state.nonce, - env: state.env, - }; -}; - -export const encodeState = (state: OAuthState): string => { - const searchParams = new URLSearchParams(); - searchParams.append('nonce', state.nonce); - searchParams.append('env', state.env); - - return encodeURIComponent(searchParams.toString()); -}; - -export const verifyNonce = (req: express.Request, providerId: string) => { - const cookieNonce = req.cookies[`${providerId}-nonce`]; - const state: OAuthState = readState(req.query.state?.toString() ?? ''); - const stateNonce = state.nonce; - - if (!cookieNonce) { - throw new Error('Auth response is missing cookie nonce'); - } - if (stateNonce.length === 0) { - throw new Error('Auth response is missing state nonce'); - } - if (cookieNonce !== stateNonce) { - throw new Error('Invalid nonce'); - } -}; - -export const postMessageResponse = ( - res: express.Response, - appOrigin: string, - response: WebMessageResponse, -) => { - const jsonData = JSON.stringify(response); - const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); - - res.setHeader('Content-Type', 'text/html'); - res.setHeader('X-Frame-Options', 'sameorigin'); - - // TODO: Make target app origin configurable globally - const script = ` - (window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}') - window.close() - `; - const hash = crypto.createHash('sha256').update(script).digest('base64'); - res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`); - - res.end(` - - - - - - `); -}; - -export const ensuresXRequestedWith = (req: express.Request) => { - const requiredHeader = req.header('X-Requested-With'); - - if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') { - return false; - } - return true; -}; - -export class OAuthProvider implements AuthProviderRouteHandlers { - private readonly domain: string; - private readonly basePath: string; constructor( - private readonly providerHandlers: OAuthProviderHandlers, + private readonly handlers: OAuthHandlers, private readonly options: Options, - ) { - const url = new URL(options.baseUrl); - this.domain = url.hostname; - this.basePath = url.pathname; - } + ) {} async start(req: express.Request, res: express.Response): Promise { // retrieve scopes from request @@ -149,17 +86,11 @@ export class OAuthProvider implements AuthProviderRouteHandlers { // set a nonce cookie before redirecting to oauth provider this.setNonceCookie(res, nonce); - const stateObject = { nonce: nonce, env: env }; - const stateParameter = encodeState(stateObject); + const state = { nonce: nonce, env: env }; + const forwardReq = Object.assign(req, { scope, state }); - const queryParameters = { - scope, - state: stateParameter, - }; - - const { url, status } = await this.providerHandlers.start( - req, - queryParameters, + const { url, status } = await this.handlers.start( + forwardReq as OAuthStartRequest, ); res.statusCode = status || 302; @@ -176,9 +107,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers { // verify nonce cookie and state cookie on callback verifyNonce(req, this.options.providerId); - const { response, refreshToken } = await this.providerHandlers.handler( - req, - ); + const { response, refreshToken } = await this.handlers.handler(req); if (this.options.persistScopes) { const grantedScopes = this.getScopesFromCookie( @@ -235,7 +164,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers { return; } - if (!this.providerHandlers.refresh || this.options.disableRefresh) { + if (!this.handlers.refresh || this.options.disableRefresh) { res.send( `Refresh token not supported for provider: ${this.options.providerId}`, ); @@ -253,30 +182,28 @@ export class OAuthProvider implements AuthProviderRouteHandlers { const scope = req.query.scope?.toString() ?? ''; + const forwardReq = Object.assign(req, { scope, refreshToken }); + // get new access_token - const response = await this.providerHandlers.refresh(refreshToken, scope); + const response = await this.handlers.refresh( + forwardReq as OAuthRefreshRequest, + ); await this.populateIdentity(response.backstageIdentity); + if ( + response.providerInfo.refreshToken && + response.providerInfo.refreshToken !== refreshToken + ) { + this.setRefreshTokenCookie(res, response.providerInfo.refreshToken); + } + res.send(response); } catch (error) { res.status(401).send(`${error.message}`); } } - identifyEnv(req: express.Request): string | undefined { - const reqEnv = req.query.env?.toString(); - if (reqEnv) { - return reqEnv; - } - const stateParams = req.query.state?.toString(); - if (!stateParams) { - return undefined; - } - const env = readState(stateParams).env; - return env; - } - /** * If the response from the OAuth provider includes a Backstage identity, we * make sure it's populated with all the information we can derive from the user ID. @@ -298,8 +225,8 @@ export class OAuthProvider implements AuthProviderRouteHandlers { maxAge: TEN_MINUTES_MS, secure: this.options.secure, sameSite: 'lax', - domain: this.domain, - path: `${this.basePath}/${this.options.providerId}/handler`, + domain: this.options.cookieDomain, + path: `${this.options.cookiePath}/handler`, httpOnly: true, }); }; @@ -309,8 +236,8 @@ export class OAuthProvider implements AuthProviderRouteHandlers { maxAge: TEN_MINUTES_MS, secure: this.options.secure, sameSite: 'lax', - domain: this.domain, - path: `${this.basePath}/${this.options.providerId}/handler`, + domain: this.options.cookieDomain, + path: `${this.options.cookiePath}/handler`, httpOnly: true, }); }; @@ -327,8 +254,8 @@ export class OAuthProvider implements AuthProviderRouteHandlers { maxAge: THOUSAND_DAYS_MS, secure: this.options.secure, sameSite: 'lax', - domain: this.domain, - path: `${this.basePath}/${this.options.providerId}`, + domain: this.options.cookieDomain, + path: this.options.cookiePath, httpOnly: true, }); }; @@ -336,10 +263,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers { private removeRefreshTokenCookie = (res: express.Response) => { res.cookie(`${this.options.providerId}-refresh-token`, '', { maxAge: 0, - secure: false, + secure: this.options.secure, sameSite: 'lax', - domain: `${this.domain}`, - path: `${this.basePath}/${this.options.providerId}`, + domain: this.options.cookieDomain, + path: this.options.cookiePath, httpOnly: true, }); }; diff --git a/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts new file mode 100644 index 0000000000..d22fc52499 --- /dev/null +++ b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import { Config } from '@backstage/config'; +import { InputError } from '@backstage/backend-common'; +import { readState } from './helpers'; +import { AuthProviderRouteHandlers } from '../../providers/types'; + +export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers { + static mapConfig( + config: Config, + factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers, + ) { + const envs = config.keys(); + const handlers = new Map(); + + for (const env of envs) { + const envConfig = config.getConfig(env); + const handler = factoryFunc(envConfig); + handlers.set(env, handler); + } + + return new OAuthEnvironmentHandler(handlers); + } + + constructor( + private readonly handlers: Map, + ) {} + + async start(req: express.Request, res: express.Response): Promise { + const provider = this.getProviderForEnv(req, res); + await provider?.start(req, res); + } + + async frameHandler( + req: express.Request, + res: express.Response, + ): Promise { + const provider = this.getProviderForEnv(req, res); + await provider?.frameHandler(req, res); + } + + async refresh(req: express.Request, res: express.Response): Promise { + const provider = this.getProviderForEnv(req, res); + await provider?.refresh?.(req, res); + } + + async logout(req: express.Request, res: express.Response): Promise { + const provider = this.getProviderForEnv(req, res); + await provider?.logout?.(req, res); + } + + private getRequestFromEnv(req: express.Request): string | undefined { + const reqEnv = req.query.env?.toString(); + if (reqEnv) { + return reqEnv; + } + const stateParams = req.query.state?.toString(); + if (!stateParams) { + return undefined; + } + const env = readState(stateParams).env; + return env; + } + + private getProviderForEnv( + req: express.Request, + res: express.Response, + ): AuthProviderRouteHandlers | undefined { + const env: string | undefined = this.getRequestFromEnv(req); + + if (!env) { + throw new InputError(`Must specify 'env' query to select environment`); + } + + if (!this.handlers.has(env)) { + res.status(404).send( + `Missing configuration. +
+
+ For this flow to work you need to supply a valid configuration for the "${env}" environment of provider.`, + ); + return undefined; + } + + return this.handlers.get(env); + } +} diff --git a/plugins/auth-backend/src/lib/oauth/helpers.test.ts b/plugins/auth-backend/src/lib/oauth/helpers.test.ts new file mode 100644 index 0000000000..fcf56705d2 --- /dev/null +++ b/plugins/auth-backend/src/lib/oauth/helpers.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import { verifyNonce, encodeState } from './helpers'; + +describe('OAuthProvider Utils', () => { + describe('verifyNonce', () => { + it('should throw error if cookie nonce missing', () => { + const state = { nonce: 'NONCE', env: 'development' }; + const mockRequest = ({ + cookies: {}, + query: { + state: encodeState(state), + }, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).toThrowError('Auth response is missing cookie nonce'); + }); + + it('should throw error if state nonce missing', () => { + const mockRequest = ({ + cookies: { + 'providera-nonce': 'NONCE', + }, + query: {}, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).toThrowError('Invalid state passed via request'); + }); + + it('should throw error if nonce mismatch', () => { + const state = { nonce: 'NONCEB', env: 'development' }; + const mockRequest = ({ + cookies: { + 'providera-nonce': 'NONCEA', + }, + query: { + state: encodeState(state), + }, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).toThrowError('Invalid nonce'); + }); + + it('should not throw any error if nonce matches', () => { + const state = { nonce: 'NONCE', env: 'development' }; + const mockRequest = ({ + cookies: { + 'providera-nonce': 'NONCE', + }, + query: { + state: encodeState(state), + }, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).not.toThrow(); + }); + }); +}); diff --git a/plugins/auth-backend/src/lib/oauth/helpers.ts b/plugins/auth-backend/src/lib/oauth/helpers.ts new file mode 100644 index 0000000000..9f250a0285 --- /dev/null +++ b/plugins/auth-backend/src/lib/oauth/helpers.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import { OAuthState } from './types'; + +export const readState = (stateString: string): OAuthState => { + const state = Object.fromEntries( + new URLSearchParams(decodeURIComponent(stateString)), + ); + if ( + !state.nonce || + !state.env || + state.nonce?.length === 0 || + state.env?.length === 0 + ) { + throw Error(`Invalid state passed via request`); + } + return { + nonce: state.nonce, + env: state.env, + }; +}; + +export const encodeState = (state: OAuthState): string => { + const searchParams = new URLSearchParams(); + searchParams.append('nonce', state.nonce); + searchParams.append('env', state.env); + + return encodeURIComponent(searchParams.toString()); +}; + +export const verifyNonce = (req: express.Request, providerId: string) => { + const cookieNonce = req.cookies[`${providerId}-nonce`]; + const state: OAuthState = readState(req.query.state?.toString() ?? ''); + const stateNonce = state.nonce; + + if (!cookieNonce) { + throw new Error('Auth response is missing cookie nonce'); + } + if (stateNonce.length === 0) { + throw new Error('Auth response is missing state nonce'); + } + if (cookieNonce !== stateNonce) { + throw new Error('Invalid nonce'); + } +}; diff --git a/plugins/auth-backend/src/lib/oauth/index.ts b/plugins/auth-backend/src/lib/oauth/index.ts new file mode 100644 index 0000000000..564a0e1e7c --- /dev/null +++ b/plugins/auth-backend/src/lib/oauth/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler'; +export { OAuthAdapter } from './OAuthAdapter'; +export { encodeState } from './helpers'; +export type { + OAuthHandlers, + OAuthProviderInfo, + OAuthProviderOptions, + OAuthResponse, + OAuthState, + OAuthStartRequest, + OAuthRefreshRequest, +} from './types'; diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts new file mode 100644 index 0000000000..b2b7915a4e --- /dev/null +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import { AuthResponse, RedirectInfo } from '../../providers/types'; + +/** + * Common options for passport.js-based OAuth providers + */ +export type OAuthProviderOptions = { + /** + * Client ID of the auth provider. + */ + clientId: string; + /** + * Client Secret of the auth provider. + */ + clientSecret: string; + /** + * Callback URL to be passed to the auth provider to redirect to after the user signs in. + */ + callbackUrl: string; +}; + +export type OAuthResponse = AuthResponse; + +export type OAuthProviderInfo = { + /** + * An access token issued for the signed in user. + */ + accessToken: string; + /** + * (Optional) Id token issued for the signed in user. + */ + idToken?: string; + /** + * Expiry of the access token in seconds. + */ + expiresInSeconds?: number; + /** + * Scopes granted for the access token. + */ + scope: string; + /** + * A refresh token issued for the signed in user + */ + refreshToken?: string; +}; + +export type OAuthState = { + /* A type for the serialized value in the `state` parameter of the OAuth authorization flow + */ + nonce: string; + env: string; +}; + +export type OAuthStartRequest = express.Request<{}> & { + scope: string; + state: OAuthState; +}; + +export type OAuthRefreshRequest = express.Request<{}> & { + scope: string; + refreshToken: string; +}; + +/** + * Any OAuth provider needs to implement this interface which has provider specific + * handlers for different methods to perform authentication, get access tokens, + * refresh tokens and perform sign out. + */ +export interface OAuthHandlers { + /** + * This method initiates a sign in request with an auth provider. + * @param {express.Request} req + * @param options + */ + start(req: OAuthStartRequest): Promise; + + /** + * Handles the redirect from the auth provider when the user has signed in. + * @param {express.Request} req + */ + handler( + req: express.Request, + ): Promise<{ + response: AuthResponse; + refreshToken?: string; + }>; + + /** + * (Optional) Given a refresh token and scope fetches a new access token from the auth provider. + * @param {string} refreshToken + * @param {string} scope + */ + refresh?(req: OAuthRefreshRequest): Promise>; + + /** + * (Optional) Sign out of the auth provider. + */ + logout?(): Promise; +} diff --git a/plugins/auth-backend/src/lib/PassportStrategyHelper.test.ts b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts similarity index 100% rename from plugins/auth-backend/src/lib/PassportStrategyHelper.test.ts rename to plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts diff --git a/plugins/auth-backend/src/lib/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts similarity index 88% rename from plugins/auth-backend/src/lib/PassportStrategyHelper.ts rename to plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts index 4cc0de4444..7bc34186f4 100644 --- a/plugins/auth-backend/src/lib/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts @@ -17,12 +17,13 @@ import express from 'express'; import passport from 'passport'; import jwtDecoder from 'jwt-decode'; -import { - RedirectInfo, - RefreshTokenResponse, - ProfileInfo, - ProviderStrategy, -} from '../providers/types'; +import { ProfileInfo, RedirectInfo } from '../../providers/types'; + +export type PassportDoneCallback = ( + err?: Error, + response?: Res, + privateInfo?: Private, +) => void; export const makeProfileInfo = ( profile: passport.Profile, @@ -45,7 +46,6 @@ export const makeProfileInfo = ( if ((!email || !picture) && idToken) { try { const decoded: Record = jwtDecoder(idToken); - if (!email && decoded.email) { email = decoded.email; } @@ -107,6 +107,18 @@ export const executeFrameHandlerStrategy = async ( ); }; +type RefreshTokenResponse = { + /** + * An access token issued for the signed in user. + */ + accessToken: string; + /** + * Optionally, the server can issue a new Refresh Token for the user + */ + refreshToken?: string; + params: any; +}; + export const executeRefreshTokenStrategy = async ( providerStrategy: passport.Strategy, refreshToken: string, @@ -133,7 +145,7 @@ export const executeRefreshTokenStrategy = async ( ( err: Error | null, accessToken: string, - _refreshToken: string, + newRefreshToken: string, params: any, ) => { if (err) { @@ -149,6 +161,7 @@ export const executeRefreshTokenStrategy = async ( resolve({ accessToken, + refreshToken: newRefreshToken, params, }); }, @@ -156,6 +169,10 @@ export const executeRefreshTokenStrategy = async ( }); }; +type ProviderStrategy = { + userProfile(accessToken: string, callback: Function): void; +}; + export const executeFetchUserProfileStrategy = async ( providerStrategy: passport.Strategy, accessToken: string, diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/index.ts b/plugins/auth-backend/src/lib/passport/index.ts similarity index 70% rename from plugins/jenkins/src/pages/BuildWithStepsPage/index.ts rename to plugins/auth-backend/src/lib/passport/index.ts index fddff7088c..c307e212fa 100644 --- a/plugins/jenkins/src/pages/BuildWithStepsPage/index.ts +++ b/plugins/auth-backend/src/lib/passport/index.ts @@ -13,7 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { - default as DetailedViewPage, - BuildWithSteps, -} from './BuildWithStepsPage'; + executeFetchUserProfileStrategy, + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, +} from './PassportStrategyHelper'; +export type { PassportDoneCallback } from './PassportStrategyHelper'; diff --git a/plugins/auth-backend/src/providers/auth0/index.ts b/plugins/auth-backend/src/providers/auth0/index.ts new file mode 100644 index 0000000000..87c9aceaa4 --- /dev/null +++ b/plugins/auth-backend/src/providers/auth0/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { createAuth0Provider } from './provider'; diff --git a/plugins/auth-backend/src/providers/auth0/provider.ts b/plugins/auth-backend/src/providers/auth0/provider.ts new file mode 100644 index 0000000000..668ab17ee1 --- /dev/null +++ b/plugins/auth-backend/src/providers/auth0/provider.ts @@ -0,0 +1,175 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import passport from 'passport'; +import Auth0Strategy from './strategy'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, + OAuthStartRequest, + encodeState, + OAuthRefreshRequest, +} from '../../lib/oauth'; +import { + executeFetchUserProfileStrategy, + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, + PassportDoneCallback, +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; + +type PrivateInfo = { + refreshToken: string; +}; + +export type Auth0AuthProviderOptions = OAuthProviderOptions & { + domain: string; +}; + +export class Auth0AuthProvider implements OAuthHandlers { + private readonly _strategy: Auth0Strategy; + + constructor(options: Auth0AuthProviderOptions) { + this._strategy = new Auth0Strategy( + { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + domain: options.domain, + passReqToCallback: false as true, + }, + ( + accessToken: any, + refreshToken: any, + params: any, + rawProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + const profile = makeProfileInfo(rawProfile, params.id_token); + done( + undefined, + { + providerInfo: { + idToken: params.id_token, + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + }, + profile, + }, + { + refreshToken, + }, + ); + }, + ); + } + + async start(req: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this._strategy, { + accessType: 'offline', + prompt: 'consent', + scope: req.scope, + state: encodeState(req.state), + }); + } + + async handler( + req: express.Request, + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { response, privateInfo } = await executeFrameHandlerStrategy< + OAuthResponse, + PrivateInfo + >(req, this._strategy); + + return { + response: await this.populateIdentity(response), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh(req: OAuthRefreshRequest): Promise { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + req.refreshToken, + req.scope, + ); + + const profile = await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + params.id_token, + ); + + return this.populateIdentity({ + providerInfo: { + accessToken, + idToken: params.id_token, + expiresInSeconds: params.expires_in, + scope: params.scope, + }, + profile, + }); + } + + // Use this function to grab the user profile info from the token + // Then populate the profile with it + private async populateIdentity( + response: OAuthResponse, + ): Promise { + const { profile } = response; + + if (!profile.email) { + throw new Error('Profile does not contain a profile'); + } + + const id = profile.email.split('@')[0]; + + return { ...response, backstageIdentity: { id } }; + } +} + +export const createAuth0Provider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'auth0'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const domain = envConfig.getString('domain'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + + const provider = new Auth0AuthProvider({ + clientId, + clientSecret, + callbackUrl, + domain, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: true, + providerId, + tokenIssuer, + }); + }); diff --git a/plugins/auth-backend/src/providers/auth0/strategy.ts b/plugins/auth-backend/src/providers/auth0/strategy.ts new file mode 100644 index 0000000000..6ac06ec4e9 --- /dev/null +++ b/plugins/auth-backend/src/providers/auth0/strategy.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import OAuth2Strategy from 'passport-oauth2'; + +export interface Auth0StrategyOptionsWithRequest { + clientID: string; + clientSecret: string; + callbackURL: string; + domain: string; + passReqToCallback: true; +} + +export default class Auth0Strategy extends OAuth2Strategy { + constructor( + options: Auth0StrategyOptionsWithRequest, + verify: OAuth2Strategy.VerifyFunctionWithRequest, + ) { + const optionsWithURLs = { + ...options, + authorizationURL: `https://${options.domain}/authorize`, + tokenURL: `https://${options.domain}/oauth/token`, + userInfoURL: `https://${options.domain}/userinfo`, + apiUrl: `https://${options.domain}/api`, + }; + super(optionsWithURLs, verify); + } +} diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 701eef81af..6c83dffda3 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -23,16 +23,10 @@ import { createGoogleProvider } from './google'; import { createOAuth2Provider } from './oauth2'; import { createOktaProvider } from './okta'; import { createSamlProvider } from './saml'; -import { - AuthProviderConfig, - AuthProviderFactory, - EnvironmentIdentifierFn, -} from './types'; +import { createAuth0Provider } from './auth0'; +import { createMicrosoftProvider } from './microsoft'; +import { AuthProviderConfig, AuthProviderFactory } from './types'; import { Config } from '@backstage/config'; -import { - EnvironmentHandlers, - EnvironmentHandler, -} from '../lib/EnvironmentHandler'; const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, @@ -40,15 +34,17 @@ const factories: { [providerId: string]: AuthProviderFactory } = { gitlab: createGitlabProvider, saml: createSamlProvider, okta: createOktaProvider, + auth0: createAuth0Provider, + microsoft: createMicrosoftProvider, oauth2: createOAuth2Provider, }; export const createAuthProviderRouter = ( providerId: string, globalConfig: AuthProviderConfig, - providerConfig: Config, + config: Config, logger: Logger, - issuer: TokenIssuer, + tokenIssuer: TokenIssuer, ) => { const factory = factories[providerId]; if (!factory) { @@ -56,28 +52,8 @@ export const createAuthProviderRouter = ( } const router = Router(); - const envs = providerConfig.keys(); - const envProviders: EnvironmentHandlers = {}; - let envIdentifier: EnvironmentIdentifierFn | undefined; - for (const env of envs) { - const envConfig = providerConfig.getConfig(env); - const provider = factory(globalConfig, env, envConfig, logger, issuer); - if (provider) { - envProviders[env] = provider; - envIdentifier = provider.identifyEnv; - } - } - - if (typeof envIdentifier === 'undefined') { - throw Error(`No envIdentifier provided for '${providerId}'`); - } - - const handler = new EnvironmentHandler( - providerId, - envProviders, - envIdentifier, - ); + const handler = factory({ globalConfig, config, logger, tokenIssuer }); router.get('/start', handler.start.bind(handler)); router.get('/handler/frame', handler.frameHandler.bind(handler)); diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index 863cfa9f5a..bab7b3fc28 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -20,22 +20,27 @@ import { executeFrameHandlerStrategy, executeRedirectStrategy, makeProfileInfo, -} from '../../lib/PassportStrategyHelper'; -import { - OAuthProviderHandlers, - AuthProviderConfig, - RedirectInfo, - OAuthProviderOptions, - OAuthResponse, PassportDoneCallback, -} from '../types'; -import { OAuthProvider } from '../../lib/OAuthProvider'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity'; +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, + OAuthStartRequest, + encodeState, +} from '../../lib/oauth'; import passport from 'passport'; -import { Config } from '@backstage/config'; -export class GithubAuthProvider implements OAuthProviderHandlers { +export type GithubAuthProviderOptions = OAuthProviderOptions & { + tokenUrl?: string; + userProfileUrl?: string; + authorizationUrl?: string; +}; + +export class GithubAuthProvider implements OAuthHandlers { private readonly _strategy: GithubStrategy; static transformPassportProfile(rawProfile: any): passport.Profile { @@ -68,7 +73,7 @@ export class GithubAuthProvider implements OAuthProviderHandlers { idToken: params.id_token, }; - // Github provides an id numeric value (123) + // GitHub provides an id numeric value (123) // as a fallback const id = passportProfile!.id; @@ -87,9 +92,16 @@ export class GithubAuthProvider implements OAuthProviderHandlers { }; } - constructor(options: OAuthProviderOptions) { + constructor(options: GithubAuthProviderOptions) { this._strategy = new GithubStrategy( - { ...options }, + { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + tokenURL: options.tokenUrl, + userProfileURL: options.userProfileUrl, + authorizationURL: options.authorizationUrl, + }, ( accessToken: any, _: any, @@ -107,11 +119,11 @@ export class GithubAuthProvider implements OAuthProviderHandlers { ); } - async start( - req: express.Request, - options: Record, - ): Promise { - return await executeRedirectStrategy(req, this._strategy, options); + async start(req: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this._strategy, { + scope: req.scope, + state: encodeState(req.state), + }); } async handler(req: express.Request) { @@ -124,60 +136,42 @@ export class GithubAuthProvider implements OAuthProviderHandlers { } } -export function createGithubProvider( - { baseUrl }: AuthProviderConfig, - _: string, - envConfig: Config, - logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'github'; - const secure = envConfig.getBoolean('secure'); - const appOrigin = envConfig.getString('appOrigin'); - const clientID = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const enterpriseInstanceUrl = envConfig.getOptionalString( - 'enterpriseInstanceUrl', - ); - const authorizationURL = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/login/oauth/authorize` - : undefined; - const tokenURL = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/login/oauth/access_token` - : undefined; - const userProfileURL = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/api/v3/user` - : undefined; - const callbackURL = `${baseUrl}/${providerId}/handler/frame`; - - const opts = { - clientID, - clientSecret, - authorizationURL, - tokenURL, - userProfileURL, - callbackURL, - }; - - if (!opts.clientID || !opts.clientSecret) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Github auth provider, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars', - ); - } - - logger.warn( - 'Github auth provider disabled, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars to enable', +export const createGithubProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'github'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const enterpriseInstanceUrl = envConfig.getOptionalString( + 'enterpriseInstanceUrl', ); - return undefined; - } - return new OAuthProvider(new GithubAuthProvider(opts), { - disableRefresh: true, - persistScopes: true, - providerId, - secure, - baseUrl, - appOrigin, - tokenIssuer, + const authorizationUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/authorize` + : undefined; + const tokenUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/access_token` + : undefined; + const userProfileUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/api/v3/user` + : undefined; + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + + const provider = new GithubAuthProvider({ + clientId, + clientSecret, + callbackUrl, + tokenUrl, + userProfileUrl, + authorizationUrl, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: true, + persistScopes: true, + providerId, + tokenIssuer, + }); }); -} diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts index 22d0bd4f0a..4d4ecc3b56 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -20,22 +20,25 @@ import { executeFrameHandlerStrategy, executeRedirectStrategy, makeProfileInfo, -} from '../../lib/PassportStrategyHelper'; -import { - OAuthProviderHandlers, - AuthProviderConfig, - RedirectInfo, - OAuthProviderOptions, - OAuthResponse, PassportDoneCallback, -} from '../types'; -import { OAuthProvider } from '../../lib/OAuthProvider'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity'; +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, + OAuthStartRequest, + encodeState, +} from '../../lib/oauth'; import passport from 'passport'; -import { Config } from '@backstage/config'; -export class GitlabAuthProvider implements OAuthProviderHandlers { +export type GitlabAuthProviderOptions = OAuthProviderOptions & { + baseUrl: string; +}; + +export class GitlabAuthProvider implements OAuthHandlers { private readonly _strategy: GitlabStrategy; static transformPassportProfile(rawProfile: any): passport.Profile { @@ -96,9 +99,14 @@ export class GitlabAuthProvider implements OAuthProviderHandlers { }; } - constructor(options: OAuthProviderOptions) { + constructor(options: GitlabAuthProviderOptions) { this._strategy = new GitlabStrategy( - { ...options }, + { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + baseURL: options.baseUrl, + }, ( accessToken: any, _: any, @@ -116,11 +124,11 @@ export class GitlabAuthProvider implements OAuthProviderHandlers { ); } - async start( - req: express.Request, - options: Record, - ): Promise { - return await executeRedirectStrategy(req, this._strategy, options); + async start(req: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this._strategy, { + scope: req.scope, + state: encodeState(req.state), + }); } async handler(req: express.Request): Promise<{ response: OAuthResponse }> { @@ -131,47 +139,29 @@ export class GitlabAuthProvider implements OAuthProviderHandlers { } } -export function createGitlabProvider( - { baseUrl }: AuthProviderConfig, - _: string, - envConfig: Config, - logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'gitlab'; - const secure = envConfig.getBoolean('secure'); - const appOrigin = envConfig.getString('appOrigin'); - const clientID = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const audience = envConfig.getString('audience'); - const baseURL = audience || 'https://gitlab.com'; - const callbackURL = `${baseUrl}/${providerId}/handler/frame`; +export const createGitlabProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'gitlab'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getString('audience'); + const baseUrl = audience || 'https://gitlab.com'; + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const opts = { - clientID, - clientSecret, - callbackURL, - baseURL, - }; + const provider = new GitlabAuthProvider({ + clientId, + clientSecret, + callbackUrl, + baseUrl, + }); - if (!opts.clientID || !opts.clientSecret) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Gitlab auth provider, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars', - ); - } - - logger.warn( - 'Gitlab auth provider disabled, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars to enable', - ); - return undefined; - } - return new OAuthProvider(new GitlabAuthProvider(opts), { - disableRefresh: true, - providerId, - secure, - baseUrl, - appOrigin, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: true, + providerId, + tokenIssuer, + }); }); -} diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index b8070949f9..3cc585605c 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -22,34 +22,39 @@ import { executeRefreshTokenStrategy, makeProfileInfo, executeFetchUserProfileStrategy, -} from '../../lib/PassportStrategyHelper'; + PassportDoneCallback, +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; import { - OAuthProviderHandlers, - RedirectInfo, - AuthProviderConfig, + OAuthAdapter, + OAuthHandlers, OAuthProviderOptions, OAuthResponse, - PassportDoneCallback, -} from '../types'; -import { OAuthProvider } from '../../lib/OAuthProvider'; + OAuthEnvironmentHandler, + OAuthStartRequest, + encodeState, + OAuthRefreshRequest, +} from '../../lib/oauth'; import passport from 'passport'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity'; -import { Config } from '@backstage/config'; type PrivateInfo = { refreshToken: string; }; -export class GoogleAuthProvider implements OAuthProviderHandlers { +export class GoogleAuthProvider implements OAuthHandlers { private readonly _strategy: GoogleStrategy; constructor(options: OAuthProviderOptions) { // TODO: throw error if env variables not set? this._strategy = new GoogleStrategy( - // We need passReqToCallback set to false to get params, but there's - // no matching type signature for that, so instead behold this beauty - { ...options, passReqToCallback: false as true }, + { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + // We need passReqToCallback set to false to get params, but there's + // no matching type signature for that, so instead behold this beauty + passReqToCallback: false as true, + }, ( accessToken: any, refreshToken: any, @@ -77,16 +82,13 @@ export class GoogleAuthProvider implements OAuthProviderHandlers { ); } - async start( - req: express.Request, - options: Record, - ): Promise { - const providerOptions = { - ...options, + async start(req: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this._strategy, { accessType: 'offline', prompt: 'consent', - }; - return await executeRedirectStrategy(req, this._strategy, providerOptions); + scope: req.scope, + state: encodeState(req.state), + }); } async handler( @@ -103,11 +105,11 @@ export class GoogleAuthProvider implements OAuthProviderHandlers { }; } - async refresh(refreshToken: string, scope: string): Promise { + async refresh(req: OAuthRefreshRequest): Promise { const { accessToken, params } = await executeRefreshTokenStrategy( this._strategy, - refreshToken, - scope, + req.refreshToken, + req.scope, ); const profile = await executeFetchUserProfileStrategy( @@ -143,44 +145,26 @@ export class GoogleAuthProvider implements OAuthProviderHandlers { } } -export function createGoogleProvider( - { baseUrl }: AuthProviderConfig, - _: string, - envConfig: Config, - logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'google'; - const secure = envConfig.getBoolean('secure'); - const appOrigin = envConfig.getString('appOrigin'); - const clientID = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const callbackURL = `${baseUrl}/${providerId}/handler/frame`; +export const createGoogleProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'google'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const opts = { - clientID, - clientSecret, - callbackURL, - }; + const provider = new GoogleAuthProvider({ + clientId, + clientSecret, + callbackUrl, + }); - if (!opts.clientID || !opts.clientSecret) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Google auth provider, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars', - ); - } - - logger.warn( - 'Google auth provider disabled, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars to enable', - ); - return undefined; - } - return new OAuthProvider(new GoogleAuthProvider(opts), { - disableRefresh: false, - providerId, - secure, - baseUrl, - appOrigin, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); -} diff --git a/plugins/auth-backend/src/providers/microsoft/index.ts b/plugins/auth-backend/src/providers/microsoft/index.ts new file mode 100644 index 0000000000..2e4abd2d2c --- /dev/null +++ b/plugins/auth-backend/src/providers/microsoft/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { createMicrosoftProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts new file mode 100644 index 0000000000..baa66f0662 --- /dev/null +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -0,0 +1,237 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import passport from 'passport'; +import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; + +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, + executeFetchUserProfileStrategy, + PassportDoneCallback, +} from '../../lib/passport'; + +import { RedirectInfo, AuthProviderFactory } from '../types'; + +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, + OAuthStartRequest, + encodeState, + OAuthRefreshRequest, +} from '../../lib/oauth'; + +import got from 'got'; + +type PrivateInfo = { + refreshToken: string; +}; + +export type MicrosoftAuthProviderOptions = OAuthProviderOptions & { + authorizationUrl?: string; + tokenUrl?: string; +}; + +export class MicrosoftAuthProvider implements OAuthHandlers { + private readonly _strategy: MicrosoftStrategy; + + static transformAuthResponse( + accessToken: string, + params: any, + rawProfile: any, + photoURL: any, + ): OAuthResponse { + let passportProfile: passport.Profile = rawProfile; + passportProfile = { + ...passportProfile, + photos: [{ value: photoURL }], + }; + + const profile = makeProfileInfo(passportProfile, params.id_token); + const providerInfo = { + idToken: params.id_token, + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + }; + + return { + providerInfo, + profile, + }; + } + + constructor(options: MicrosoftAuthProviderOptions) { + this._strategy = new MicrosoftStrategy( + { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + authorizationURL: options.authorizationUrl, + tokenURL: options.tokenUrl, + passReqToCallback: false as true, + }, + ( + accessToken: any, + refreshToken: any, + params: any, + rawProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + this.getUserPhoto(accessToken) + .then(photoURL => { + const authResponse = MicrosoftAuthProvider.transformAuthResponse( + accessToken, + params, + rawProfile, + photoURL, + ); + done(undefined, authResponse, { refreshToken }); + }) + .catch(error => { + throw new Error(`Error processing auth response: ${error}`); + }); + }, + ); + } + + async start(req: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this._strategy, { + scope: req.scope, + state: encodeState(req.state), + }); + } + + async handler( + req: express.Request, + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { response, privateInfo } = await executeFrameHandlerStrategy< + OAuthResponse, + PrivateInfo + >(req, this._strategy); + + return { + response: await this.populateIdentity(response), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh(req: OAuthRefreshRequest): Promise { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + req.refreshToken, + req.scope, + ); + + const profile = await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + params.id_token, + ); + const photo = await this.getUserPhoto(accessToken); + if (photo) { + profile.picture = photo; + } + + return this.populateIdentity({ + providerInfo: { + accessToken, + idToken: params.id_token, + expiresInSeconds: params.expires_in, + scope: params.scope, + }, + profile, + }); + } + + private getUserPhoto(accessToken: string): Promise { + return new Promise(resolve => { + got + .get('https://graph.microsoft.com/v1.0/me/photos/48x48/$value', { + encoding: 'binary', + responseType: 'buffer', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .then(photoData => { + const photoURL = `data:image/jpeg;base64,${Buffer.from( + photoData.body, + ).toString('base64')}`; + resolve(photoURL); + }) + .catch(error => { + console.log( + `Could not retrieve user profile photo from Microsoft Graph API: ${error}`, + ); + // User profile photo is optional, ignore errors and resolve undefined + resolve(); + }); + }); + } + + private async populateIdentity( + response: OAuthResponse, + ): Promise { + const { profile } = response; + + if (!profile.email) { + throw new Error('Microsoft profile contained no email'); + } + + // Like Google implementation, setting this to local part of email for now + const id = profile.email.split('@')[0]; + + return { ...response, backstageIdentity: { id } }; + } +} + +export const createMicrosoftProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'microsoft'; + + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const tenantID = envConfig.getString('tenantId'); + + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const authorizationUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`; + const tokenUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`; + + const provider = new MicrosoftAuthProvider({ + clientId, + clientSecret, + callbackUrl, + authorizationUrl, + tokenUrl, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); + }); diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts index c03f7c4371..a657ea2195 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -17,36 +17,48 @@ import express from 'express'; import passport from 'passport'; import { Strategy as OAuth2Strategy } from 'passport-oauth2'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity'; -import { OAuthProvider } from '../../lib/OAuthProvider'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, + OAuthStartRequest, + encodeState, + OAuthRefreshRequest, +} from '../../lib/oauth'; import { executeFetchUserProfileStrategy, executeFrameHandlerStrategy, executeRedirectStrategy, executeRefreshTokenStrategy, makeProfileInfo, -} from '../../lib/PassportStrategyHelper'; -import { - AuthProviderConfig, - GenericOAuth2ProviderOptions, - OAuthProviderHandlers, - OAuthResponse, PassportDoneCallback, - RedirectInfo, -} from '../types'; -import { Config } from '@backstage/config'; +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; type PrivateInfo = { refreshToken: string; }; -export class OAuth2AuthProvider implements OAuthProviderHandlers { +export type OAuth2AuthProviderOptions = OAuthProviderOptions & { + authorizationUrl: string; + tokenUrl: string; +}; + +export class OAuth2AuthProvider implements OAuthHandlers { private readonly _strategy: OAuth2Strategy; - constructor(options: GenericOAuth2ProviderOptions) { + constructor(options: OAuth2AuthProviderOptions) { this._strategy = new OAuth2Strategy( - { ...options, passReqToCallback: false as true }, + { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + authorizationURL: options.authorizationUrl, + tokenURL: options.tokenUrl, + passReqToCallback: false as true, + }, ( accessToken: any, refreshToken: any, @@ -55,6 +67,7 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers { done: PassportDoneCallback, ) => { const profile = makeProfileInfo(rawProfile, params.id_token); + done( undefined, { @@ -74,16 +87,13 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers { ); } - async start( - req: express.Request, - options: Record, - ): Promise { - const providerOptions = { - ...options, + async start(req: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this._strategy, { accessType: 'offline', prompt: 'consent', - }; - return await executeRedirectStrategy(req, this._strategy, providerOptions); + scope: req.scope, + state: encodeState(req.state), + }); } async handler( @@ -100,12 +110,17 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers { }; } - async refresh(refreshToken: string, scope: string): Promise { - const { accessToken, params } = await executeRefreshTokenStrategy( + async refresh(req: OAuthRefreshRequest): Promise { + const refreshTokenResponse = await executeRefreshTokenStrategy( this._strategy, - refreshToken, - scope, + req.refreshToken, + req.scope, ); + const { + accessToken, + params, + refreshToken: updatedRefreshToken, + } = refreshTokenResponse; const profile = await executeFetchUserProfileStrategy( this._strategy, @@ -116,6 +131,7 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers { return this.populateIdentity({ providerInfo: { accessToken, + refreshToken: updatedRefreshToken, idToken: params.id_token, expiresInSeconds: params.expires_in, scope: params.scope, @@ -134,60 +150,36 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers { if (!profile.email) { throw new Error('Profile does not contain a profile'); } - const id = profile.email.split('@')[0]; return { ...response, backstageIdentity: { id } }; } } -export function createOAuth2Provider( - { baseUrl }: AuthProviderConfig, - _: string, - envConfig: Config, - logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'oauth2'; - const secure = envConfig.getBoolean('secure'); - const appOrigin = envConfig.getString('appOrigin'); - const clientID = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const callbackURL = `${baseUrl}/${providerId}/handler/frame`; - const authorizationURL = envConfig.getString('authorizationURL'); - const tokenURL = envConfig.getString('tokenURL'); +export const createOAuth2Provider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'oauth2'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const authorizationUrl = envConfig.getString('authorizationUrl'); + const tokenUrl = envConfig.getString('tokenUrl'); - const opts = { - clientID, - clientSecret, - callbackURL, - authorizationURL, - tokenURL, - }; + const provider = new OAuth2AuthProvider({ + clientId, + clientSecret, + callbackUrl, + authorizationUrl, + tokenUrl, + }); - if ( - !opts.clientID || - !opts.clientSecret || - !opts.authorizationURL || - !opts.tokenURL - ) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize OAuth2 auth provider, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars', - ); - } - - logger.warn( - 'OAuth2 auth provider disabled, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars to enable', - ); - return undefined; - } - return new OAuthProvider(new OAuth2AuthProvider(opts), { - disableRefresh: false, - providerId, - secure, - baseUrl, - appOrigin, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); -} diff --git a/plugins/auth-backend/src/providers/okta/index.ts b/plugins/auth-backend/src/providers/okta/index.ts index bc32601ac2..05cc398f43 100644 --- a/plugins/auth-backend/src/providers/okta/index.ts +++ b/plugins/auth-backend/src/providers/okta/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { createOktaProvider } from './provider'; +export { createOktaProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 058300cef6..09597696ba 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -14,7 +14,16 @@ * limitations under the License. */ import express from 'express'; -import { OAuthProvider } from '../../lib/OAuthProvider'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, + OAuthStartRequest, + encodeState, + OAuthRefreshRequest, +} from '../../lib/oauth'; import { Strategy as OktaStrategy } from 'passport-okta-oauth'; import passport from 'passport'; import { @@ -23,25 +32,20 @@ import { executeRefreshTokenStrategy, makeProfileInfo, executeFetchUserProfileStrategy, -} from '../../lib/PassportStrategyHelper'; -import { - OAuthProviderHandlers, - RedirectInfo, - AuthProviderConfig, - OAuthProviderOptions, - OAuthResponse, PassportDoneCallback, -} from '../types'; -import { Logger } from 'winston'; +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; import { StateStore } from 'passport-oauth2'; -import { TokenIssuer } from '../../identity'; -import { Config } from '@backstage/config'; type PrivateInfo = { refreshToken: string; }; -export class OktaAuthProvider implements OAuthProviderHandlers { +export type OktaAuthProviderOptions = OAuthProviderOptions & { + audience: string; +}; + +export class OktaAuthProvider implements OAuthHandlers { private readonly _strategy: any; /** @@ -61,11 +65,14 @@ export class OktaAuthProvider implements OAuthProviderHandlers { }, }; - constructor(options: OAuthProviderOptions) { + constructor(options: OktaAuthProviderOptions) { this._strategy = new OktaStrategy( { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + audience: options.audience, passReqToCallback: false as true, - ...options, store: this._store, response_type: 'code', }, @@ -97,16 +104,13 @@ export class OktaAuthProvider implements OAuthProviderHandlers { ); } - async start( - req: express.Request, - options: Record, - ): Promise { - const providerOptions = { - ...options, + async start(req: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this._strategy, { accessType: 'offline', prompt: 'consent', - }; - return await executeRedirectStrategy(req, this._strategy, providerOptions); + scope: req.scope, + state: encodeState(req.state), + }); } async handler( @@ -123,11 +127,11 @@ export class OktaAuthProvider implements OAuthProviderHandlers { }; } - async refresh(refreshToken: string, scope: string): Promise { + async refresh(req: OAuthRefreshRequest): Promise { const { accessToken, params } = await executeRefreshTokenStrategy( this._strategy, - refreshToken, - scope, + req.refreshToken, + req.scope, ); const profile = await executeFetchUserProfileStrategy( @@ -163,46 +167,28 @@ export class OktaAuthProvider implements OAuthProviderHandlers { } } -export function createOktaProvider( - { baseUrl }: AuthProviderConfig, - _: string, - envConfig: Config, - logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'okta'; - const secure = envConfig.getBoolean('secure'); - const appOrigin = envConfig.getString('appOrigin'); - const clientID = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const audience = envConfig.getString('audience'); - const callbackURL = `${baseUrl}/${providerId}/handler/frame`; +export const createOktaProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'okta'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getString('audience'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const opts = { - audience, - clientID, - clientSecret, - callbackURL, - }; + const provider = new OktaAuthProvider({ + audience, + clientId, + clientSecret, + callbackUrl, + }); - if (!opts.clientID || !opts.clientSecret || !opts.audience) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Okta auth provider, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars', - ); - } - - logger.warn( - 'Okta auth provider disabled, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars to enable', - ); - return undefined; - } - return new OAuthProvider(new OktaAuthProvider(opts), { - disableRefresh: false, - providerId, - secure, - baseUrl, - appOrigin, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); -} diff --git a/plugins/auth-backend/src/providers/okta/types.d.ts b/plugins/auth-backend/src/providers/okta/types.d.ts index bed6d24043..6b49d99817 100644 --- a/plugins/auth-backend/src/providers/okta/types.d.ts +++ b/plugins/auth-backend/src/providers/okta/types.d.ts @@ -14,9 +14,7 @@ * limitations under the License. */ declare module 'passport-okta-oauth' { - export class Strategy { - constructor(options: any, verify: any) + constructor(options: any, verify: any); } } - \ No newline at end of file diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 1ccee1a9cb..2bd8ed0bf3 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -23,17 +23,15 @@ import { import { executeFrameHandlerStrategy, executeRedirectStrategy, -} from '../../lib/PassportStrategyHelper'; -import { - AuthProviderConfig, - AuthProviderRouteHandlers, PassportDoneCallback, +} from '../../lib/passport'; +import { + AuthProviderRouteHandlers, ProfileInfo, + AuthProviderFactory, } from '../types'; -import { postMessageResponse } from '../../lib/OAuthProvider'; -import { Logger } from 'winston'; +import { postMessageResponse } from '../../lib/flow'; import { TokenIssuer } from '../../identity'; -import { Config } from '@backstage/config'; type SamlInfo = { userId: string; @@ -119,15 +117,12 @@ type SAMLProviderOptions = { tokenIssuer: TokenIssuer; }; -export function createSamlProvider( - _authProviderConfig: AuthProviderConfig, - _env: string, - envConfig: Config, - logger: Logger, - tokenIssuer: TokenIssuer, -) { - const entryPoint = envConfig.getString('entryPoint'); - const issuer = envConfig.getString('issuer'); +export const createSamlProvider: AuthProviderFactory = ({ + config, + tokenIssuer, +}) => { + const entryPoint = config.getString('entryPoint'); + const issuer = config.getString('issuer'); const opts = { entryPoint, issuer, @@ -135,11 +130,5 @@ export function createSamlProvider( tokenIssuer, }; - if (!opts.entryPoint || !opts.issuer) { - logger.warn( - 'SAML auth provider disabled, set entryPoint and entryPoint in saml auth config to enable', - ); - return undefined; - } return new SamlAuthProvider(opts); -} +}; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index d372fd1b94..5c05b02739 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -18,74 +18,6 @@ import express from 'express'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity'; import { Config } from '@backstage/config'; -import { OAuthProvider } from '../lib/OAuthProvider'; -import { SamlAuthProvider } from './saml/provider'; - -export type OAuthProviderOptions = { - /** - * Client ID of the auth provider. - */ - clientID: string; - /** - * Client Secret of the auth provider. - */ - clientSecret: string; - /** - * Callback URL to be passed to the auth provider to redirect to after the user signs in. - */ - callbackURL: string; -}; - -export type GenericOAuth2ProviderOptions = OAuthProviderOptions & { - authorizationURL: string; - tokenURL: string; -}; - -export type OAuthProviderConfig = { - /** - * Cookies can be marked with a secure flag to send cookies only when the request - * is over an encrypted channel (HTTPS). - * - * For development environment we don't mark the cookie as secure since we serve - * localhost over HTTP. - */ - secure: boolean; - /** - * The protocol://domain[:port] where the app (frontend) is hosted. This is used to post messages back - * to the window that initiates an auth request. - */ - appOrigin: string; - /** - * Client ID of the auth provider. - */ - clientId: string; - /** - * Client Secret of the auth provider. - */ - clientSecret: string; - /** - * The location of the OAuth Authorization Server - */ - audience?: string; -}; - -export type GenericOAuth2ProviderConfig = OAuthProviderConfig & { - authorizationURL: string; - tokenURL: string; -}; - -export type EnvironmentProviderConfig = { - /** - * key, values are environment names and OAuthProviderConfigs - * - * For e.g - * { - * development: DevelopmentOAuthProviderConfig - * production: ProductionOAuthProviderConfig - * } - */ - [key: string]: OAuthProviderConfig; -}; export type AuthProviderConfig = { /** @@ -93,50 +25,23 @@ export type AuthProviderConfig = { * callbackURL to redirect to once the user signs in to the auth provider. */ baseUrl: string; + + /** + * The base URL of the app as provided by app.baseUrl + */ + appUrl: string; }; -/** - * Any OAuth provider needs to implement this interface which has provider specific - * handlers for different methods to perform authentication, get access tokens, - * refresh tokens and perform sign out. - */ -export interface OAuthProviderHandlers { +export type RedirectInfo = { /** - * This method initiates a sign in request with an auth provider. - * @param {express.Request} req - * @param options + * URL to redirect to */ - start( - req: express.Request, - options: Record, - ): Promise; - + url: string; /** - * Handles the redirect from the auth provider when the user has signed in. - * @param {express.Request} req + * Status code to use for the redirect */ - handler( - req: express.Request, - ): Promise<{ - response: AuthResponse; - refreshToken?: string; - }>; - - /** - * (Optional) Given a refresh token and scope fetches a new access token from the auth provider. - * @param {string} refreshToken - * @param {string} scope - */ - refresh?( - refreshToken: string, - scope: string, - ): Promise>; - - /** - * (Optional) Sign out of the auth provider. - */ - logout?(): Promise; -} + status?: number; +}; /** * Any Auth provider needs to implement this interface which handles the routes in the @@ -203,24 +108,18 @@ export interface AuthProviderRouteHandlers { * @param {express.Response} res */ logout?(req: express.Request, res: express.Response): Promise; - - /** - *(Optional) A method to identify the environment Context of the Request - * - *Request - *- contains the environment context information encoded in the request - * @param {express.Request} req - */ - identifyEnv?(req: express.Request): string | undefined; } +export type AuthProviderFactoryOptions = { + globalConfig: AuthProviderConfig; + config: Config; + logger: Logger; + tokenIssuer: TokenIssuer; +}; + export type AuthProviderFactory = ( - globalConfig: AuthProviderConfig, - env: string, - envConfig: Config, - logger: Logger, - issuer: TokenIssuer, -) => OAuthProvider | SamlAuthProvider | undefined; + options: AuthProviderFactoryOptions, +) => AuthProviderRouteHandlers; export type AuthResponse = { providerInfo: ProviderInfo; @@ -228,8 +127,6 @@ export type AuthResponse = { backstageIdentity?: BackstageIdentity; }; -export type OAuthResponse = AuthResponse; - export type BackstageIdentity = { /** * The backstage user ID. @@ -242,63 +139,6 @@ export type BackstageIdentity = { idToken?: string; }; -export type OAuthProviderInfo = { - /** - * An access token issued for the signed in user. - */ - accessToken: string; - /** - * (Optional) Id token issued for the signed in user. - */ - idToken?: string; - /** - * Expiry of the access token in seconds. - */ - expiresInSeconds?: number; - /** - * Scopes granted for the access token. - */ - scope: string; -}; - -export type OAuthPrivateInfo = { - /** - * A refresh token issued for the signed in user. - */ - refreshToken: string; -}; - -/** - * Payload sent as a post message after the auth request is complete. - * If successful then has a valid payload with Auth information else contains an error. - */ -export type WebMessageResponse = - | { - type: 'authorization_response'; - response: AuthResponse; - } - | { - type: 'authorization_response'; - error: Error; - }; - -export type PassportDoneCallback = ( - err?: Error, - response?: Res, - privateInfo?: Private, -) => void; - -export type RedirectInfo = { - /** - * URL to redirect to - */ - url: string; - /** - * Status code to use for the redirect - */ - status?: number; -}; - /** * Used to display login information to user, i.e. sidebar popup. * @@ -320,35 +160,3 @@ export type ProfileInfo = { */ picture?: string; }; - -export type RefreshTokenResponse = { - /** - * An access token issued for the signed in user. - */ - accessToken: string; - params: any; -}; - -export type ProviderStrategy = { - userProfile(accessToken: string, callback: Function): void; -}; - -export type SAMLProviderConfig = { - entryPoint: string; - issuer: string; -}; - -export type SAMLEnvironmentProviderConfig = { - [key: string]: SAMLProviderConfig; -}; - -export type OAuthState = { - /* A type for the serialized value in the `state` parameter of the OAuth authorization flow - */ - nonce: string; - env: string; -}; - -export type EnvironmentIdentifierFn = ( - req: express.Request, -) => string | undefined; diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 69f058f6db..19a74d47c5 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -17,12 +17,12 @@ import express from 'express'; import Router from 'express-promise-router'; import cookieParser from 'cookie-parser'; -import bodyParser from 'body-parser'; import Knex from 'knex'; import { Logger } from 'winston'; import { createAuthProviderRouter } from '../providers'; import { Config } from '@backstage/config'; import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity'; +import { NotFoundError } from '@backstage/backend-common'; export interface RouterOptions { logger: Logger; @@ -36,6 +36,7 @@ export async function createRouter( const router = Router(); const logger = options.logger.child({ plugin: 'auth' }); + const appUrl = options.config.getString('app.baseUrl'); const backendUrl = options.config.getString('backend.baseUrl'); const authUrl = `${backendUrl}/auth`; @@ -52,8 +53,8 @@ export async function createRouter( }); router.use(cookieParser()); - router.use(bodyParser.urlencoded({ extended: false })); - router.use(bodyParser.json()); + router.use(express.urlencoded({ extended: false })); + router.use(express.json()); const providersConfig = options.config.getConfig('auth.providers'); const providers = providersConfig.keys(); @@ -64,14 +65,20 @@ export async function createRouter( const providerConfig = providersConfig.getConfig(providerId); const providerRouter = createAuthProviderRouter( providerId, - { baseUrl: authUrl }, + { baseUrl: authUrl, appUrl }, providerConfig, logger, tokenIssuer, ); router.use(`/${providerId}`, providerRouter); } catch (e) { - logger.error(e.message); + if (process.env.NODE_ENV !== 'development') { + throw new Error( + `Failed to initialize ${providerId} auth provider, ${e.message}`, + ); + } + + logger.warn(`Skipping ${providerId} auth provider, ${e.message}`); } } @@ -82,5 +89,10 @@ export async function createRouter( }), ); + router.use('/:provider/', req => { + const { provider } = req.params; + throw new NotFoundError(`No auth provider registered for '${provider}'`); + }); + return router; } diff --git a/plugins/catalog-backend/README.md b/plugins/catalog-backend/README.md index 246cd83d56..1b4a653f4a 100644 --- a/plugins/catalog-backend/README.md +++ b/plugins/catalog-backend/README.md @@ -21,14 +21,11 @@ To evaluate the catalog and have a greater amount of functionality available, in # in one terminal window, run this from from the very root of the Backstage project cd packages/backend yarn start - -# open another terminal window, and run the following from the very root of the Backstage project -yarn lerna run mock-data ``` -This will launch the full example backend and populate its catalog with some mock entities. +This will launch the full example backend, populated some example entities. ## Links -- (Frontend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/catalog] -- (The Backstage homepage)[https://backstage.io] +- [Frontend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/catalog) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 24e342882f..d1711cf3d5 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -17,14 +17,12 @@ "test": "backstage-cli test", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean", - "mock-data": "./scripts/mock-data.sh", - "mock-data:local": "./scripts/mock-data-local.sh" + "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.18", - "@backstage/catalog-model": "^0.1.1-alpha.18", - "@backstage/config": "^0.1.1-alpha.18", + "@backstage/backend-common": "^0.1.1-alpha.21", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/config": "^0.1.1-alpha.21", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -41,14 +39,14 @@ "yup": "^0.29.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", "@types/lodash": "^4.14.151", "@types/node-fetch": "^2.5.7", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", "@types/yup": "^0.28.2", "jest-fetch-mock": "^3.0.3", - "msw": "^0.19.5", + "msw": "^0.20.5", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/catalog-backend/scripts/mock-data-local.sh b/plugins/catalog-backend/scripts/mock-data-local.sh deleted file mode 100755 index 3bea819319..0000000000 --- a/plugins/catalog-backend/scripts/mock-data-local.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -for FILE in \ - ../../packages/catalog-model/examples/*.yaml \ -; do \ - curl \ - --location \ - --request POST 'localhost:7000/catalog/locations' \ - --header 'Content-Type: application/json' \ - --data-raw "{\"type\": \"file\", \"target\": \"../catalog-model/${FILE}\"}" - echo -done diff --git a/plugins/catalog-backend/scripts/mock-data.sh b/plugins/catalog-backend/scripts/mock-data.sh deleted file mode 100755 index 76f97ccf3a..0000000000 --- a/plugins/catalog-backend/scripts/mock-data.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -for URL in \ - 'artist-lookup-component.yaml' \ - 'playback-order-component.yaml' \ - 'podcast-api-component.yaml' \ - 'queue-proxy-component.yaml' \ - 'searcher-component.yaml' \ - 'playback-lib-component.yaml' \ - 'www-artist-component.yaml' \ - 'shuffle-api-component.yaml' \ -; do \ - curl \ - --location \ - --request POST 'localhost:7000/catalog/locations' \ - --header 'Content-Type: application/json' \ - --data-raw "{\"type\": \"github\", \"target\": \"https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/${URL}\"}" - echo -done diff --git a/plugins/catalog-backend/src/database/DatabaseManager.ts b/plugins/catalog-backend/src/database/DatabaseManager.ts index 28751f4830..ce91cc737f 100644 --- a/plugins/catalog-backend/src/database/DatabaseManager.ts +++ b/plugins/catalog-backend/src/database/DatabaseManager.ts @@ -14,17 +14,16 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; +import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common'; import { makeValidator } from '@backstage/catalog-model'; import Knex from 'knex'; -import path from 'path'; import { Logger } from 'winston'; import { CommonDatabase } from './CommonDatabase'; import { Database } from './types'; -const migrationsDir = path.resolve( - require.resolve('@backstage/plugin-catalog-backend/package.json'), - '../migrations', +const migrationsDir = resolvePackagePath( + '@backstage/plugin-catalog-backend', + 'migrations', ); export type CreateDatabaseOptions = { diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts new file mode 100644 index 0000000000..bb5c025c28 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts @@ -0,0 +1,217 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocationSpec, Entity } from '@backstage/catalog-model'; +import { CatalogRulesEnforcer } from './CatalogRules'; +import { ConfigReader } from '@backstage/config'; + +const entity = { + user: { + kind: 'User', + } as Entity, + group: { + kind: 'Group', + } as Entity, + component: { + kind: 'component', + } as Entity, + location: { + kind: 'Location', + } as Entity, +}; + +const location: Record = { + x: { + type: 'github', + target: 'https://github.com/a/b/blob/master/x.yaml', + }, + y: { + type: 'github', + target: 'https://github.com/a/b/blob/master/y.yaml', + }, + z: { + type: 'file', + target: '/root/z.yaml', + }, +}; + +describe('CatalogRulesEnforcer', () => { + it('should deny by default', () => { + const enforcer = new CatalogRulesEnforcer([]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(false); + expect(enforcer.isAllowed(entity.location, location.z)).toBe(false); + }); + + it('should deny all', () => { + const enforcer = new CatalogRulesEnforcer([{ allow: [] }]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(false); + expect(enforcer.isAllowed(entity.location, location.z)).toBe(false); + }); + + it('should allow all', () => { + const enforcer = new CatalogRulesEnforcer([ + { + allow: ['User', 'Group', 'Component', 'Location'].map(kind => ({ + kind, + })), + }, + ]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(true); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + expect(enforcer.isAllowed(entity.location, location.z)).toBe(true); + }); + + it('should deny groups', () => { + const enforcer = new CatalogRulesEnforcer([ + { allow: [{ kind: 'User' }, { kind: 'Component' }] }, + ]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.z)).toBe(false); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + }); + + it('should deny groups from github', () => { + const enforcer = new CatalogRulesEnforcer([ + { allow: [{ kind: 'User' }, { kind: 'Component' }] }, + { allow: [{ kind: 'Group' }], locations: [{ type: 'file' }] }, + ]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.z)).toBe(true); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + }); + + it('should allow groups from files', () => { + const enforcer = new CatalogRulesEnforcer([ + { allow: [{ kind: 'Group' }], locations: [{ type: 'file' }] }, + ]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.z)).toBe(true); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(false); + }); + + it('should not be sensitive to kind case', () => { + const enforcer = new CatalogRulesEnforcer([ + { allow: [{ kind: 'group' }] }, + { allow: [{ kind: 'Component' }] }, + ]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.z)).toBe(true); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + }); + + describe('fromConfig', () => { + it('should allow components by default', () => { + const enforcer = CatalogRulesEnforcer.fromConfig(new ConfigReader({})); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + expect(enforcer.isAllowed(entity.location, location.z)).toBe(true); + }); + + it('should deny all', () => { + const enforcer = CatalogRulesEnforcer.fromConfig( + new ConfigReader({ catalog: { rules: [] } }), + ); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(false); + expect(enforcer.isAllowed(entity.location, location.z)).toBe(false); + }); + + it('should allow all', () => { + const enforcer = CatalogRulesEnforcer.fromConfig( + new ConfigReader({ + catalog: { + rules: [{ allow: ['User', 'Group'] }, { allow: ['Component'] }], + }, + }), + ); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(true); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + }); + + it('should deny groups', () => { + const enforcer = CatalogRulesEnforcer.fromConfig( + new ConfigReader({ + catalog: { rules: [{ allow: ['User'] }, { allow: ['Component'] }] }, + }), + ); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.z)).toBe(false); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + expect(enforcer.isAllowed(entity.location, location.z)).toBe(false); + }); + + it('should allow groups from a specific github location', () => { + const enforcer = CatalogRulesEnforcer.fromConfig( + new ConfigReader({ + catalog: { + rules: [{ allow: ['user'] }], + locations: [ + { + type: 'github', + target: 'https://github.com/a/b/blob/master/x.yaml', + rules: [ + { + allow: ['Group'], + }, + ], + }, + ], + }, + }), + ); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.z)).toBe(false); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(false); + expect(enforcer.isAllowed(entity.location, location.z)).toBe(false); + }); + + it('should not care about location configuration in catalog.rules', () => { + const enforcer = CatalogRulesEnforcer.fromConfig( + new ConfigReader({ + catalog: { + rules: [{ allow: ['Group'], locations: [{ type: 'github' }] }], + }, + }), + ); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.z)).toBe(true); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(false); + expect(enforcer.isAllowed(entity.location, location.z)).toBe(false); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.ts new file mode 100644 index 0000000000..e964524bc7 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { LocationSpec, Entity } from '@backstage/catalog-model'; + +/** + * A structure for matching entities to a given rule. + */ +type EntityMatcher = { + kind: string; +}; + +/** + * A structure for matching locations to a given rule. + */ +type LocationMatcher = { + target?: string; + type: string; +}; + +/** + * Rules to apply to catalog entities + * + * An undefined list of matchers means match all, an empty list of matchers means match none + */ +type CatalogRule = { + allow: EntityMatcher[]; + locations?: LocationMatcher[]; +}; + +export class CatalogRulesEnforcer { + /** + * Default rules used by the catalog. + * + * Denies any location from specifying user or group entities. + */ + static readonly defaultRules: CatalogRule[] = [ + { + allow: ['Component', 'API', 'Location'].map(kind => ({ kind })), + }, + ]; + + /** + * Loads catalog rules from config. + * + * This reads `catalog.rules` and defaults to the default rules if no value is present. + * The value of the config should be a list of config objects, each with a single `allow` + * field which in turn is a list of entity kinds to allow. + * + * If there is no matching rule to allow an ingested entity, it will be rejected by the catalog. + * + * It also reads in rules from `catalog.locations`, where each location can have a list + * of rules for that specific location, specified in a `rules` field. + * + * For example: + * + * ```yaml + * catalog: + * rules: + * - allow: [Component, API] + * + * locations: + * - type: github + * target: https://github.com/org/repo/blob/master/users.yaml + * rules: + * - allow: [User, Group] + * - type: github + * target: https://github.com/org/repo/blob/master/systems.yaml + * rules: + * - allow: [System] + * ``` + */ + static fromConfig(config: Config) { + const rules = new Array(); + + if (config.has('catalog.rules')) { + const globalRules = config.getConfigArray('catalog.rules').map(sub => ({ + allow: sub.getStringArray('allow').map(kind => ({ kind })), + })); + rules.push(...globalRules); + } else { + rules.push(...CatalogRulesEnforcer.defaultRules); + } + + if (config.has('catalog.locations')) { + const locationRules = config + .getConfigArray('catalog.locations') + .flatMap(locConf => { + if (!locConf.has('rules')) { + return []; + } + const type = locConf.getString('type'); + const target = locConf.getString('target'); + + return locConf.getConfigArray('rules').map(ruleConf => ({ + allow: ruleConf.getStringArray('allow').map(kind => ({ kind })), + locations: [{ type, target }], + })); + }); + + rules.push(...locationRules); + } + + return new CatalogRulesEnforcer(rules); + } + + constructor(private readonly rules: CatalogRule[]) {} + + /** + * Checks wether a specific entity/location combination is allowed + * according to the configured rules. + */ + isAllowed(entity: Entity, location: LocationSpec) { + for (const rule of this.rules) { + if (!this.matchLocation(location, rule.locations)) { + continue; + } + + if (this.matchEntity(entity, rule.allow)) { + return true; + } + } + + return false; + } + + private matchLocation( + location: LocationSpec, + matchers?: LocationMatcher[], + ): boolean { + if (!matchers) { + return true; + } + + for (const matcher of matchers) { + if (matcher.type !== location.type) { + continue; + } + if (matcher.target && matcher.target !== location.target) { + continue; + } + return true; + } + + return false; + } + + private matchEntity(entity: Entity, matchers?: EntityMatcher[]): boolean { + if (!matchers) { + return true; + } + + for (const matcher of matchers) { + if (entity.kind.toLowerCase() !== matcher.kind.toLowerCase()) { + continue; + } + + return true; + } + + return false; + } +} diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 7e91af7c59..6f3a27f00d 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -30,6 +30,8 @@ import { GithubReaderProcessor } from './processors/GithubReaderProcessor'; import { GithubApiReaderProcessor } from './processors/GithubApiReaderProcessor'; import { GitlabApiReaderProcessor } from './processors/GitlabApiReaderProcessor'; import { GitlabReaderProcessor } from './processors/GitlabReaderProcessor'; +import { BitbucketApiReaderProcessor } from './processors/BitbucketApiReaderProcessor'; +import { AzureApiReaderProcessor } from './processors/AzureApiReaderProcessor'; import { UrlReaderProcessor } from './processors/UrlReaderProcessor'; import { LocationRefProcessor } from './processors/LocationEntityProcessor'; import { StaticLocationProcessor } from './processors/StaticLocationProcessor'; @@ -45,6 +47,7 @@ import { } from './processors/types'; import { YamlProcessor } from './processors/YamlProcessor'; import { LocationReader, ReadLocationResult } from './types'; +import { CatalogRulesEnforcer } from './CatalogRules'; // The max amount of nesting depth of generated work items const MAX_DEPTH = 10; @@ -61,6 +64,7 @@ type Options = { export class LocationReaders implements LocationReader { private readonly logger: Logger; private readonly processors: LocationProcessor[]; + private readonly rulesEnforcer: CatalogRulesEnforcer; static defaultProcessors(options: { config?: Config; @@ -73,10 +77,12 @@ export class LocationReaders implements LocationReader { return [ StaticLocationProcessor.fromConfig(config), new FileReaderProcessor(), - new GithubReaderProcessor(), - new GithubApiReaderProcessor(), - new GitlabApiReaderProcessor(), + new GithubReaderProcessor(config), + GithubApiReaderProcessor.fromConfig(config), + new GitlabApiReaderProcessor(config), new GitlabReaderProcessor(), + new BitbucketApiReaderProcessor(config), + new AzureApiReaderProcessor(config), new UrlReaderProcessor(), new YamlProcessor(), new EntityPolicyProcessor(entityPolicy), @@ -92,6 +98,9 @@ export class LocationReaders implements LocationReader { }: Options) { this.logger = logger; this.processors = processors; + this.rulesEnforcer = config + ? CatalogRulesEnforcer.fromConfig(config) + : new CatalogRulesEnforcer(CatalogRulesEnforcer.defaultRules); } async read(location: LocationSpec): Promise { @@ -108,11 +117,20 @@ export class LocationReaders implements LocationReader { } else if (item.type === 'data') { await this.handleData(item, emit); } else if (item.type === 'entity') { - const entity = await this.handleEntity(item, emit); - output.entities.push({ - entity, - location: item.location, - }); + if (this.rulesEnforcer.isAllowed(item.entity, item.location)) { + const entity = await this.handleEntity(item, emit); + output.entities.push({ + entity, + location: item.location, + }); + } else { + output.errors.push({ + location: item.location, + error: new Error( + `Entity of kind ${item.entity.kind} is not allowed from location ${item.location.target}:${item.location.type}`, + ), + }); + } } else if (item.type === 'error') { await this.handleError(item, emit); output.errors.push({ diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.test.ts new file mode 100644 index 0000000000..9b234e3e75 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AzureApiReaderProcessor } from './AzureApiReaderProcessor'; +import { ConfigReader } from '@backstage/config'; + +describe('AzureApiReaderProcessor', () => { + const createConfig = (token: string | undefined) => + ConfigReader.fromConfigs([ + { + context: '', + data: { + catalog: { + processors: { + azureApi: { + privateToken: token, + }, + }, + }, + }, + }, + ]); + + it('should build raw api', () => { + const processor = new AzureApiReaderProcessor(createConfig(undefined)); + const tests = [ + { + target: + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', + url: new URL( + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', + ), + err: undefined, + }, + { + target: + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml', + url: new URL( + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', + ), + err: undefined, + }, + { + target: 'https://api.com/a/b/blob/master/path/to/c.yaml', + url: null, + err: + 'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', + }, + { + target: 'com/a/b/blob/master/path/to/c.yaml', + url: null, + err: + 'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', + }, + ]; + + for (const test of tests) { + if (test.err) { + expect(() => processor.buildRawUrl(test.target)).toThrowError(test.err); + } else if (test.url) { + expect(processor.buildRawUrl(test.target).toString()).toEqual( + test.url.toString(), + ); + } else { + throw new Error( + 'This should not have happened. Either err or url should have matched.', + ); + } + } + }); + + it('should return request options', () => { + const tests = [ + { + token: '0123456789', + expect: { + headers: { + Authorization: 'Basic OjAxMjM0NTY3ODk=', + }, + }, + }, + { + token: '', + expect: { + headers: {}, + }, + err: + "Invalid type in config for key 'catalog.processors.azureApi.privateToken' in '', got empty-string, wanted string", + }, + { + token: undefined, + expect: { + headers: {}, + }, + }, + ]; + + for (const test of tests) { + if (test.err) { + expect( + () => new AzureApiReaderProcessor(createConfig(test.token)), + ).toThrowError(test.err); + } else { + const processor = new AzureApiReaderProcessor(createConfig(test.token)); + expect(processor.getRequestOptions()).toEqual(test.expect); + } + } + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts new file mode 100644 index 0000000000..03ff30ea69 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocationSpec } from '@backstage/catalog-model'; +import fetch, { RequestInit, HeadersInit } from 'node-fetch'; +import * as result from './results'; +import { LocationProcessor, LocationProcessorEmit } from './types'; +import { Config } from '@backstage/config'; + +export class AzureApiReaderProcessor implements LocationProcessor { + private privateToken: string; + + constructor(config: Config) { + this.privateToken = + config.getOptionalString('catalog.processors.azureApi.privateToken') ?? + ''; + } + + getRequestOptions(): RequestInit { + const headers: HeadersInit = {}; + + if (this.privateToken !== '') { + headers.Authorization = `Basic ${Buffer.from( + `:${this.privateToken}`, + 'utf8', + ).toString('base64')}`; + } + + const requestOptions: RequestInit = { + headers, + }; + + return requestOptions; + } + + async readLocation( + location: LocationSpec, + optional: boolean, + emit: LocationProcessorEmit, + ): Promise { + if (location.type !== 'azure/api') { + return false; + } + + try { + const url = this.buildRawUrl(location.target); + + const response = await fetch(url.toString(), this.getRequestOptions()); + + // for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html + if (response.ok && response.status !== 203) { + const data = await response.buffer(); + emit(result.data(location, data)); + } else { + const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + if (!optional) { + emit(result.notFoundError(location, message)); + } + } else { + emit(result.generalError(location, message)); + } + } + } catch (e) { + const message = `Unable to read ${location.type} ${location.target}, ${e}`; + emit(result.generalError(location, message)); + } + return true; + } + + // Converts + // from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents + // to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch} + buildRawUrl(target: string): URL { + try { + const url = new URL(target); + + const [ + empty, + userOrOrg, + project, + srcKeyword, + repoName, + ] = url.pathname.split('/'); + + const path = url.searchParams.get('path') || ''; + const ref = url.searchParams.get('version')?.substr(2); + + if ( + url.hostname !== 'dev.azure.com' || + empty !== '' || + userOrOrg === '' || + project === '' || + srcKeyword !== '_git' || + repoName === '' || + path === '' || + ref === '' || + !path.match(/\.yaml$/) + ) { + throw new Error('Wrong Azure Devops URL or Invalid file path'); + } + + // transform to api + url.pathname = [ + empty, + userOrOrg, + project, + '_apis', + 'git', + 'repositories', + repoName, + 'items', + ].join('/'); + + const queryParams = [`path=${path}`]; + + if (ref) { + queryParams.push(`version=${ref}`); + } + + url.search = queryParams.join('&'); + + url.protocol = 'https'; + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.test.ts new file mode 100644 index 0000000000..953f0703be --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BitbucketApiReaderProcessor } from './BitbucketApiReaderProcessor'; +import { ConfigReader } from '@backstage/config'; + +describe('BitbucketApiReaderProcessor', () => { + const createConfig = ( + username: string | undefined, + appPassword: string | undefined, + ) => + ConfigReader.fromConfigs([ + { + context: '', + data: { + catalog: { + processors: { + bitbucketApi: { + username: username, + appPassword: appPassword, + }, + }, + }, + }, + }, + ]); + + it('should build raw api', () => { + const processor = new BitbucketApiReaderProcessor( + createConfig(undefined, undefined), + ); + + const tests = [ + { + target: + 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', + url: new URL( + 'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml', + ), + err: undefined, + }, + { + target: 'https://api.com/a/b/blob/master/path/to/c.yaml', + url: null, + err: + 'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Bitbucket URL or Invalid file path', + }, + { + target: 'com/a/b/blob/master/path/to/c.yaml', + url: null, + err: + 'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', + }, + ]; + + for (const test of tests) { + if (test.err) { + expect(() => processor.buildRawUrl(test.target)).toThrowError(test.err); + } else if (test.url) { + expect(processor.buildRawUrl(test.target).toString()).toEqual( + test.url.toString(), + ); + } else { + throw new Error( + 'This should not have happened. Either err or url should have matched.', + ); + } + } + }); + + it('should return request options', () => { + const tests = [ + { + username: '', + password: '', + expect: { + headers: {}, + }, + err: + "Invalid type in config for key 'catalog.processors.bitbucketApi.username' in '', got empty-string, wanted string", + }, + { + username: 'only-user-provided', + password: '', + expect: { + headers: {}, + }, + err: + "Invalid type in config for key 'catalog.processors.bitbucketApi.appPassword' in '', got empty-string, wanted string", + }, + { + username: '', + password: 'only-password-provided', + expect: { + headers: {}, + }, + err: + "Invalid type in config for key 'catalog.processors.bitbucketApi.username' in '', got empty-string, wanted string", + }, + { + username: 'some-user', + password: 'my-secret', + expect: { + headers: { + Authorization: 'Basic c29tZS11c2VyOm15LXNlY3JldA==', + }, + }, + }, + { + username: undefined, + password: undefined, + expect: { + headers: {}, + }, + }, + { + username: 'only-user-provided', + password: undefined, + expect: { + headers: {}, + }, + }, + { + username: undefined, + password: 'only-password-provided', + expect: { + headers: {}, + }, + }, + ]; + + for (const test of tests) { + if (test.err) { + expect( + () => + new BitbucketApiReaderProcessor( + createConfig(test.username, test.password), + ), + ).toThrowError(test.err); + } else { + const processor = new BitbucketApiReaderProcessor( + createConfig(test.username, test.password), + ); + expect(processor.getRequestOptions()).toEqual(test.expect); + } + } + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts new file mode 100644 index 0000000000..97ecf0cd1f --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocationSpec } from '@backstage/catalog-model'; +import fetch, { RequestInit, HeadersInit } from 'node-fetch'; +import * as result from './results'; +import { LocationProcessor, LocationProcessorEmit } from './types'; +import { Config } from '@backstage/config'; + +export class BitbucketApiReaderProcessor implements LocationProcessor { + private username: string; + private password: string; + + constructor(config: Config) { + this.username = + config.getOptionalString('catalog.processors.bitbucketApi.username') ?? + ''; + this.password = + config.getOptionalString('catalog.processors.bitbucketApi.appPassword') ?? + ''; + } + + getRequestOptions(): RequestInit { + const headers: HeadersInit = {}; + + if (this.username !== '' && this.password !== '') { + headers.Authorization = `Basic ${Buffer.from( + `${this.username}:${this.password}`, + 'utf8', + ).toString('base64')}`; + } + + const requestOptions: RequestInit = { + headers, + }; + + return requestOptions; + } + + async readLocation( + location: LocationSpec, + optional: boolean, + emit: LocationProcessorEmit, + ): Promise { + if (location.type !== 'bitbucket/api') { + return false; + } + + try { + const url = this.buildRawUrl(location.target); + + const response = await fetch(url.toString(), this.getRequestOptions()); + + if (response.ok) { + const data = await response.buffer(); + emit(result.data(location, data)); + } else { + const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + if (!optional) { + emit(result.notFoundError(location, message)); + } + } else { + emit(result.generalError(location, message)); + } + } + } catch (e) { + const message = `Unable to read ${location.type} ${location.target}, ${e}`; + emit(result.generalError(location, message)); + } + return true; + } + + // Converts + // from: https://bitbucket.org/orgname/reponame/src/master/file.yaml + // to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml + + buildRawUrl(target: string): URL { + try { + const url = new URL(target); + + const [ + empty, + userOrOrg, + repoName, + srcKeyword, + ref, + ...restOfPath + ] = url.pathname.split('/'); + + if ( + url.hostname !== 'bitbucket.org' || + empty !== '' || + userOrOrg === '' || + repoName === '' || + srcKeyword !== 'src' || + !restOfPath.join('/').match(/\.yaml$/) + ) { + throw new Error('Wrong Bitbucket URL or Invalid file path'); + } + + // transform to api + url.pathname = [ + empty, + '2.0', + 'repositories', + userOrOrg, + repoName, + 'src', + ref, + ...restOfPath, + ].join('/'); + url.hostname = 'api.bitbucket.org'; + url.protocol = 'https'; + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubApiReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubApiReaderProcessor.test.ts index 4dd3fd359f..48f38f0e97 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubApiReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubApiReaderProcessor.test.ts @@ -14,76 +14,149 @@ * limitations under the License. */ -import { GithubApiReaderProcessor } from './GithubApiReaderProcessor'; +import { LocationSpec } from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; +import { + getRawUrl, + getRequestOptions, + GithubApiReaderProcessor, + ProviderConfig, + readConfig, +} from './GithubApiReaderProcessor'; describe('GithubApiReaderProcessor', () => { - it('should build raw api', () => { - const processor = new GithubApiReaderProcessor(); + describe('getRequestOptions', () => { + it('sets the correct API version', () => { + const config: ProviderConfig = { target: '', apiBaseUrl: '' }; + expect((getRequestOptions(config).headers as any).Accept).toEqual( + 'application/vnd.github.v3.raw', + ); + }); - const tests = [ - { - target: 'https://github.com/a/b/blob/master/path/to/c.yaml', - url: new URL( - 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=master', - ), - err: undefined, - }, - { - target: 'https://api.com/a/b/blob/master/path/to/c.yaml', - url: null, - err: - 'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong GitHub URL or Invalid file path', - }, - { - target: 'com/a/b/blob/master/path/to/c.yaml', - url: null, - err: - 'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', - }, - { - target: - 'https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-order-component.yaml', - url: new URL( - 'https://api.github.com/repos/spotify/backstage/contents/packages/catalog-model/examples/playback-order-component.yaml?ref=master', - ), - err: undefined, - }, - ]; - - for (const test of tests) { - if (test.err) { - expect(() => processor.buildRawUrl(test.target)).toThrowError(test.err); - } else { - expect(processor.buildRawUrl(test.target)).toEqual(test.url); - } - } + it('inserts a token when needed', () => { + const withToken: ProviderConfig = { + target: '', + apiBaseUrl: '', + token: 'A', + }; + const withoutToken: ProviderConfig = { + target: '', + apiBaseUrl: '', + }; + expect( + (getRequestOptions(withToken).headers as any).Authorization, + ).toEqual('token A'); + expect( + (getRequestOptions(withoutToken).headers as any).Authorization, + ).toBeUndefined(); + }); }); - it('should return request options', () => { - const tests = [ - { - token: '0123456789', - expect: { - headers: { - Accept: 'application/vnd.github.v3.raw', - Authorization: 'token 0123456789', - }, - }, - }, - { - token: '', - expect: { - headers: { - Accept: 'application/vnd.github.v3.raw', - }, - }, - }, - ]; + describe('getRawUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: ProviderConfig = { target: '', apiBaseUrl: '' }; + expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); + }); - for (const test of tests) { - process.env.GITHUB_PRIVATE_TOKEN = test.token; - const processor = new GithubApiReaderProcessor(); - expect(processor.getRequestOptions()).toEqual(test.expect); + it('passes through the happy path', () => { + const config: ProviderConfig = { + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }; + expect( + getRawUrl( + 'https://github.com/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + new URL( + 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', + ), + ); + }); + }); + + describe('readConfig', () => { + function config( + providers: { target: string; apiBaseUrl?: string; token?: string }[], + ) { + return ConfigReader.fromConfigs([ + { + context: '', + data: { + catalog: { processors: { githubApi: { providers } } }, + }, + }, + ]); } + + it('adds a default GitHub entry when missing', () => { + const output = readConfig(config([])); + expect(output).toEqual([ + { target: 'https://github.com', apiBaseUrl: 'https://api.github.com' }, + ]); + }); + + it('injects the correct GitHub API base URL when missing', () => { + const output = readConfig(config([{ target: 'https://github.com' }])); + expect(output).toEqual([ + { target: 'https://github.com', apiBaseUrl: 'https://api.github.com' }, + ]); + }); + + it('rejects custom targets with no API base URL', () => { + expect(() => + readConfig(config([{ target: 'https://ghe.company.com' }])), + ).toThrow( + 'Provider at https://ghe.company.com must configure an explicit apiBaseUrl', + ); + }); + + it('rejects funky configs', () => { + expect(() => readConfig(config([{ target: 7 } as any]))).toThrow( + /target/, + ); + expect(() => readConfig(config([{ noTarget: '7' } as any]))).toThrow( + /target/, + ); + expect(() => + readConfig( + config([{ target: 'https://github.com', apiBaseUrl: 7 } as any]), + ), + ).toThrow(/apiBaseUrl/); + expect(() => + readConfig(config([{ target: 'https://github.com', token: 7 } as any])), + ).toThrow(/token/); + }); + }); + + describe('implementation', () => { + it('rejects unknown types', async () => { + const processor = new GithubApiReaderProcessor([ + { target: 'https://github.com', apiBaseUrl: 'https://api.github.com' }, + ]); + const location: LocationSpec = { + type: 'not-github/api', + target: 'https://github.com', + }; + await expect( + processor.readLocation(location, false, () => {}), + ).resolves.toBeFalsy(); + }); + + it('rejects unknown targets', async () => { + const processor = new GithubApiReaderProcessor([ + { target: 'https://github.com', apiBaseUrl: 'https://api.github.com' }, + ]); + const location: LocationSpec = { + type: 'github/api', + target: 'https://not.github.com/apa', + }; + await expect( + processor.readLocation(location, false, () => {}), + ).rejects.toThrow( + /There is no GitHub provider that matches https:\/\/not.github.com\/apa/, + ); + }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubApiReaderProcessor.ts index ff61d004ca..812bf6f488 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubApiReaderProcessor.ts @@ -15,27 +15,135 @@ */ import { LocationSpec } from '@backstage/catalog-model'; -import fetch, { RequestInit, HeadersInit } from 'node-fetch'; +import { Config } from '@backstage/config'; +import fetch, { HeadersInit, RequestInit } from 'node-fetch'; import * as result from './results'; import { LocationProcessor, LocationProcessorEmit } from './types'; -export class GithubApiReaderProcessor implements LocationProcessor { - private privateToken: string = process.env.GITHUB_PRIVATE_TOKEN || ''; +/** + * The configuration parameters for a single GitHub API provider. + */ +export type ProviderConfig = { + /** + * The prefix of the target that this matches on, e.g. "https://github.com", + * with no trailing slash. + */ + target: string; - getRequestOptions(): RequestInit { - const headers: HeadersInit = { - Accept: 'application/vnd.github.v3.raw', - }; + /** + * The base URL of the API of this provider, e.g. "https://api.github.com", + * with no trailing slash. + */ + apiBaseUrl: string; - if (this.privateToken !== '') { - headers.Authorization = `token ${this.privateToken}`; + /** + * The authorization token to use for requests to this provider. + * + * If no token is specified, anonymous API access is used. + */ + token?: string; +}; + +export function getRequestOptions(provider: ProviderConfig): RequestInit { + const headers: HeadersInit = { + Accept: 'application/vnd.github.v3.raw', + }; + + if (provider.token) { + headers.Authorization = `token ${provider.token}`; + } + + return { + headers, + }; +} + +// Converts for example +// from: https://github.com/a/b/blob/branchname/path/to/c.yaml +// to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname +export function getRawUrl(target: string, provider: ProviderConfig): URL { + try { + const oldPath = new URL(target).pathname.split('/'); + const [, userOrOrg, repoName, blobOrRaw, ref, ...restOfPath] = oldPath; + + if ( + !userOrOrg || + !repoName || + (blobOrRaw !== 'blob' && blobOrRaw !== 'raw') || + !restOfPath.join('/').match(/\.ya?ml$/) + ) { + throw new Error('Wrong URL or Invalid file path'); } - const requestOptions: RequestInit = { - headers, - }; + // Transform to API path + const newPath = [ + 'repos', + userOrOrg, + repoName, + 'contents', + ...restOfPath, + ].join('/'); + return new URL(`${provider.apiBaseUrl}/${newPath}?ref=${ref}`); + } catch (e) { + throw new Error(`Incorrect URL: ${target}, ${e}`); + } +} - return requestOptions; +export function readConfig(configRoot: Config): ProviderConfig[] { + const providers: ProviderConfig[] = []; + + // In a previous version of the configuration, we only supported github, + // and the "privateToken" key held the token to use for it. The new + // configuration method is to use the "providers" key instead. + const config = configRoot.getOptionalConfig('catalog.processors.githubApi'); + const providerConfigs = config?.getOptionalConfigArray('providers') ?? []; + const legacyToken = config?.getOptionalString('privateToken'); + + // First read all the explicit providers + for (const providerConfig of providerConfigs) { + const target = providerConfig.getString('target').replace(/\/+$/, ''); + let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl'); + const token = providerConfig.getOptionalString('token'); + + if (apiBaseUrl) { + apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); + } else if (target === 'https://github.com') { + apiBaseUrl = 'https://api.github.com'; + } else { + throw new Error( + `Provider at ${target} must configure an explicit apiBaseUrl`, + ); + } + + providers.push({ target, apiBaseUrl, token }); + } + + // If no explicit github.com provider was added, put one in the list as + // a convenience + if (!providers.some(p => p.target === 'https://github.com')) { + providers.push({ + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + token: legacyToken, + }); + } + + return providers; +} + +/** + * A processor that adds the ability to read files from GitHub v3 APIs, such as + * the one exposed by GitHub itself. + */ +export class GithubApiReaderProcessor implements LocationProcessor { + private providers: ProviderConfig[]; + + static fromConfig(config: Config) { + return new GithubApiReaderProcessor(readConfig(config)); + } + + constructor(providers: ProviderConfig[]) { + this.providers = providers; } async readLocation( @@ -47,10 +155,19 @@ export class GithubApiReaderProcessor implements LocationProcessor { return false; } - try { - const url = this.buildRawUrl(location.target); + const provider = this.providers.find(p => + location.target.startsWith(`${p.target}/`), + ); + if (!provider) { + throw new Error( + `There is no GitHub provider that matches ${location.target}. Please add a configuration entry for it under catalog.github.processors.githubApi.`, + ); + } - const response = await fetch(url.toString(), this.getRequestOptions()); + try { + const url = getRawUrl(location.target, provider); + const options = getRequestOptions(provider); + const response = await fetch(url.toString(), options); if (response.ok) { const data = await response.buffer(); @@ -72,50 +189,4 @@ export class GithubApiReaderProcessor implements LocationProcessor { return true; } - - // Converts - // from: https://github.com/a/b/blob/master/path/to/c.yaml - // to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=master - buildRawUrl(target: string): URL { - try { - const url = new URL(target); - - const [ - empty, - userOrOrg, - repoName, - blobKeyword, - ref, - ...restOfPath - ] = url.pathname.split('/'); - - if ( - url.hostname !== 'github.com' || - empty !== '' || - userOrOrg === '' || - repoName === '' || - blobKeyword !== 'blob' || - !restOfPath.join('/').match(/\.yaml$/) - ) { - throw new Error('Wrong GitHub URL or Invalid file path'); - } - - // transform to api - url.pathname = [ - empty, - 'repos', - userOrOrg, - repoName, - 'contents', - ...restOfPath, - ].join('/'); - url.hostname = 'api.github.com'; - url.protocol = 'https'; - url.search = `ref=${ref}`; - - return url; - } catch (e) { - throw new Error(`Incorrect url: ${target}, ${e}`); - } - } } diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts index b83c9a16f3..9f38d782fe 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts @@ -15,11 +15,35 @@ */ import { LocationSpec } from '@backstage/catalog-model'; -import fetch from 'node-fetch'; +import fetch, { RequestInit, HeadersInit } from 'node-fetch'; import * as result from './results'; import { LocationProcessor, LocationProcessorEmit } from './types'; +import { Config } from '@backstage/config'; export class GithubReaderProcessor implements LocationProcessor { + private privateToken: string; + + constructor(config?: Config) { + this.privateToken = + config?.getOptionalString('catalog.processors.github.privateToken') ?? ''; + } + + getRequestOptions(): RequestInit { + const headers: HeadersInit = { + Accept: 'application/vnd.github.v3.raw', + }; + + if (this.privateToken !== '') { + headers.Authorization = `token ${this.privateToken}`; + } + + const requestOptions: RequestInit = { + headers, + }; + + return requestOptions; + } + async readLocation( location: LocationSpec, optional: boolean, @@ -34,7 +58,7 @@ export class GithubReaderProcessor implements LocationProcessor { // TODO(freben): Should "hard" errors thrown by this line be treated as // notFound instead of fatal? - const response = await fetch(url.toString()); + const response = await fetch(url.toString(), this.getRequestOptions()); if (response.ok) { const data = await response.buffer(); diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts index a429a0e6fb..48ed270049 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts @@ -15,17 +15,34 @@ */ import { GitlabApiReaderProcessor } from './GitlabApiReaderProcessor'; +import { ConfigReader } from '@backstage/config'; describe('GitlabApiReaderProcessor', () => { + const createConfig = (token: string | undefined) => + ConfigReader.fromConfigs([ + { + context: '', + data: { + catalog: { + processors: { + gitlabApi: { + privateToken: token, + }, + }, + }, + }, + }, + ]); + it('should build raw api', () => { - const processor = new GitlabApiReaderProcessor(); + const processor = new GitlabApiReaderProcessor(createConfig(undefined)); const tests = [ { target: 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', url: new URL( - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch', + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', ), err: undefined, }, @@ -33,7 +50,7 @@ describe('GitlabApiReaderProcessor', () => { target: 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', url: new URL( - 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch', + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', ), err: undefined, }, @@ -41,7 +58,7 @@ describe('GitlabApiReaderProcessor', () => { target: 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup url: new URL( - 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch', + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', ), err: undefined, }, @@ -50,7 +67,7 @@ describe('GitlabApiReaderProcessor', () => { 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/', url: null, err: - 'Incorrect url: https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/, Error: Gitlab url does not end in .ya?ml', + 'Incorrect url: https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/, Error: GitLab url does not end in .ya?ml', }, ]; @@ -59,8 +76,14 @@ describe('GitlabApiReaderProcessor', () => { expect(() => processor.buildRawUrl(test.target, 12345)).toThrowError( test.err, ); + } else if (test.url) { + expect(processor.buildRawUrl(test.target, 12345).toString()).toEqual( + test.url.toString(), + ); } else { - expect(processor.buildRawUrl(test.target, 12345)).toEqual(test.url); + throw new Error( + 'This should not have happened. Either err or url should have matched.', + ); } } }); @@ -77,6 +100,16 @@ describe('GitlabApiReaderProcessor', () => { }, { token: '', + err: + "Invalid type in config for key 'catalog.processors.gitlabApi.privateToken' in '', got empty-string, wanted string", + expect: { + headers: { + 'PRIVATE-TOKEN': '', + }, + }, + }, + { + token: undefined, expect: { headers: { 'PRIVATE-TOKEN': '', @@ -86,9 +119,16 @@ describe('GitlabApiReaderProcessor', () => { ]; for (const test of tests) { - process.env.GITLAB_PRIVATE_TOKEN = test.token; - const processor = new GitlabApiReaderProcessor(); - expect(processor.getRequestOptions()).toEqual(test.expect); + if (test.err) { + expect( + () => new GitlabApiReaderProcessor(createConfig(test.token)), + ).toThrowError(test.err); + } else { + const processor = new GitlabApiReaderProcessor( + createConfig(test.token), + ); + expect(processor.getRequestOptions()).toEqual(test.expect); + } } }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts index 6964640eb4..067edba3d9 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts @@ -18,9 +18,16 @@ import { LocationSpec } from '@backstage/catalog-model'; import fetch, { RequestInit, HeadersInit } from 'node-fetch'; import * as result from './results'; import { LocationProcessor, LocationProcessorEmit } from './types'; +import { Config } from '@backstage/config'; export class GitlabApiReaderProcessor implements LocationProcessor { - private privateToken: string = process.env.GITLAB_PRIVATE_TOKEN || ''; + private privateToken: string; + + constructor(config: Config) { + this.privateToken = + config.getOptionalString('catalog.processors.gitlabApi.privateToken') ?? + ''; + } getRequestOptions(): RequestInit { const headers: HeadersInit = { 'PRIVATE-TOKEN': '' }; @@ -77,7 +84,7 @@ export class GitlabApiReaderProcessor implements LocationProcessor { const branchAndfilePath = url.pathname.split('/-/blob/')[1]; if (!branchAndfilePath.match(/\.ya?ml$/)) { - throw new Error('Gitlab url does not end in .ya?ml'); + throw new Error('GitLab url does not end in .ya?ml'); } const [branch, ...filePath] = branchAndfilePath.split('/'); @@ -127,7 +134,7 @@ export class GitlabApiReaderProcessor implements LocationProcessor { return projectID; } catch (e) { - throw new Error(`Could not get Gitlab ProjectID for: ${target}, ${e}`); + throw new Error(`Could not get GitLab ProjectID for: ${target}, ${e}`); } } } diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts index 211f325afe..9f308ee184 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts @@ -77,7 +77,7 @@ export class GitlabReaderProcessor implements LocationProcessor { blobKeyword !== 'blob' || !restOfPath.join('/').match(/\.yaml$/) ) { - throw new Error('Wrong Gitlab URL'); + throw new Error('Wrong GitLab URL'); } // Replace 'blob' with 'raw' diff --git a/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.test.ts new file mode 100644 index 0000000000..9f1ede3f9e --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { YamlProcessor } from './YamlProcessor'; +import { Entity } from '@backstage/catalog-model'; +import yaml from 'yaml'; +import { TextEncoder } from 'util'; +import { + LocationProcessorEntityResult, + LocationProcessorErrorResult, +} from './types'; + +describe('YamlProcessor', () => { + const processor = new YamlProcessor(); + const locationSpec = { + type: 'url', + target: 'http://example.com/component.yaml', + }; + + function encodeEntity(entity: string): Buffer { + const data = new TextEncoder().encode(entity); + return Buffer.from(data); + } + + it('should only process files with yaml', async () => { + const wrongLocationSpec = { + type: 'url', + target: 'http://example.com/component.json', + }; + + const buffer = Buffer.from([]); + const never = jest.fn(); + + expect(await processor.parseData(buffer, wrongLocationSpec, never)).toBe( + false, + ); + + expect(never).not.toBeCalled(); + }); + + it('should process url that contains yaml', async () => { + const containsYamlLocationSpec = { + type: 'url', + target: 'http://example.com/component?path=test.yaml&c=1&d=2', + }; + + const buffer = Buffer.from([]); + const emit = jest.fn(); + + expect( + await processor.parseData(buffer, containsYamlLocationSpec, emit), + ).toBe(true); + + expect(emit).toBeCalled(); + }); + + it('should process entity with yaml', async () => { + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + }, + spec: {}, + } as Entity; + + const buffer = encodeEntity(yaml.stringify(entity)); + const emit = jest.fn(); + + expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true); + + const e = emit.mock.calls[0][0] as LocationProcessorEntityResult; + expect(e.type).toBe('entity'); + expect(e.location).toBe(locationSpec); + expect(e.entity).toEqual(entity); + }); + + it('should process multiple entities with yaml', async () => { + const entityComponent = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + }, + spec: {}, + } as Entity; + + const entityApi = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { + name: 'my-api', + }, + spec: {}, + } as Entity; + + const buffer = encodeEntity( + `${yaml.stringify(entityComponent)}---\n${yaml.stringify(entityApi)}`, + ); + const emit = jest.fn(); + + expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true); + + const eComponent = emit.mock.calls[0][0] as LocationProcessorEntityResult; + expect(eComponent.type).toBe('entity'); + expect(eComponent.location).toBe(locationSpec); + expect(eComponent.entity).toEqual(entityComponent); + + const eApi = emit.mock.calls[1][0] as LocationProcessorEntityResult; + expect(eApi.type).toBe('entity'); + expect(eApi.location).toBe(locationSpec); + expect(eApi.entity).toEqual(entityApi); + }); + + it('should fail process entity on invalid yaml', async () => { + const buffer = encodeEntity('{'); + const emit = jest.fn(); + + expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true); + + const e = emit.mock.calls[0][0] as LocationProcessorErrorResult; + expect(e.error.message).toMatch(/^YAML error, /); + expect(e.type).toBe('error'); + expect(e.location).toBe(locationSpec); + }); + + it('should fail process entity if not object at root', async () => { + const buffer = encodeEntity('[]'); + const emit = jest.fn(); + + expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true); + + const e = emit.mock.calls[0][0] as LocationProcessorErrorResult; + expect(e.error.message).toMatch(/^Expected object at root, got /); + expect(e.type).toBe('error'); + expect(e.location).toBe(locationSpec); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.ts index 6a2b5cf419..79ae55fae1 100644 --- a/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.ts @@ -26,7 +26,7 @@ export class YamlProcessor implements LocationProcessor { location: LocationSpec, emit: LocationProcessorEmit, ): Promise { - if (!location.target.match(/\.ya?ml$/)) { + if (!location.target.match(/\.ya?ml/)) { return false; } diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 5531c23ade..5a3eef4cce 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-graphql", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", diff --git a/plugins/catalog/README.md b/plugins/catalog/README.md index 52bbd0d0c3..cff632cb24 100644 --- a/plugins/catalog/README.md +++ b/plugins/catalog/README.md @@ -9,5 +9,5 @@ supply the base views to show and manage them. ## Links -- (Backend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/catalog-backend] -- (The Backstage homepage)[https://backstage.io] +- [Backend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/catalog-backend) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index d91b8c03cc..e1b52795d3 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,29 +21,29 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.18", - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/plugin-api-docs": "^0.1.1-alpha.18", - "@backstage/plugin-github-actions": "^0.1.1-alpha.18", - "@backstage/plugin-jenkins": "^0.1.1-alpha.18", - "@backstage/plugin-scaffolder": "^0.1.1-alpha.18", - "@backstage/plugin-sentry": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/plugin-scaffolder": "^0.1.1-alpha.21", + "@backstage/plugin-techdocs": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "classnames": "^2.2.6", "moment": "^2.26.0", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-helmet": "6.1.0", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", - "swr": "^0.2.2" + "swr": "^0.3.0", + "@types/react": "^16.9" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", - "@backstage/test-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", @@ -51,9 +51,9 @@ "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3", - "msw": "^0.19.0", + "msw": "^0.20.5", "react-test-renderer": "^16.13.1", - "whatwg-fetch": "^2.0.0" + "whatwg-fetch": "^3.4.0" }, "files": [ "dist" diff --git a/plugins/catalog/src/api/CatalogClient.test.ts b/plugins/catalog/src/api/CatalogClient.test.ts index 7803f2e173..18f062db77 100644 --- a/plugins/catalog/src/api/CatalogClient.test.ts +++ b/plugins/catalog/src/api/CatalogClient.test.ts @@ -18,25 +18,21 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { CatalogClient } from './CatalogClient'; import { Entity } from '@backstage/catalog-model'; +import { UrlPatternDiscovery } from '@backstage/core'; const server = setupServer(); +const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; +const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); describe('CatalogClient', () => { beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); - const mockApiOrigin = 'http://backstage:9191'; - const mockBasePath = '/i-am-a-mock-base'; - let client = new CatalogClient({ - apiOrigin: mockApiOrigin, - basePath: mockBasePath, - }); + + let client = new CatalogClient({ discoveryApi }); beforeEach(() => { - client = new CatalogClient({ - apiOrigin: mockApiOrigin, - basePath: mockBasePath, - }); + client = new CatalogClient({ discoveryApi }); }); describe('getEntiies', () => { @@ -61,7 +57,7 @@ describe('CatalogClient', () => { beforeEach(() => { server.use( - rest.get(`${mockApiOrigin}${mockBasePath}/entities`, (_, res, ctx) => { + rest.get(`${mockBaseUrl}/entities`, (_, res, ctx) => { return res(ctx.json(defaultResponse)); }), ); @@ -75,15 +71,12 @@ describe('CatalogClient', () => { it('builds entity search filters properly', async () => { expect.assertions(2); server.use( - rest.get( - `${mockApiOrigin}${mockBasePath}/entities`, - (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'a=1&b=2&b=3&%C3%B6=%3D', - ); - return res(ctx.json([])); - }, - ), + rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'a=1&b=2&b=3&%C3%B6=%3D', + ); + return res(ctx.json([])); + }), ); const entities = await client.getEntities({ diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 3804315ace..d5ff033caa 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -20,24 +20,17 @@ import { LOCATION_ANNOTATION, } from '@backstage/catalog-model'; import { CatalogApi, EntityCompoundName } from './types'; +import { DiscoveryApi } from '@backstage/core'; export class CatalogClient implements CatalogApi { - private apiOrigin: string; - private basePath: string; + private readonly discoveryApi: DiscoveryApi; - constructor({ - apiOrigin, - basePath, - }: { - apiOrigin: string; - basePath: string; - }) { - this.apiOrigin = apiOrigin; - this.basePath = basePath; + constructor(options: { discoveryApi: DiscoveryApi }) { + this.discoveryApi = options.discoveryApi; } private async getRequired(path: string): Promise { - const url = `${this.apiOrigin}${this.basePath}${path}`; + const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`; const response = await fetch(url); if (!response.ok) { @@ -50,7 +43,7 @@ export class CatalogClient implements CatalogApi { } private async getOptional(path: string): Promise { - const url = `${this.apiOrigin}${this.basePath}${path}`; + const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`; const response = await fetch(url); if (!response.ok) { @@ -100,7 +93,7 @@ export class CatalogClient implements CatalogApi { async addLocation(type: string, target: string) { const response = await fetch( - `${this.apiOrigin}${this.basePath}/locations`, + `${await this.discoveryApi.getBaseUrl('catalog')}/locations`, { headers: { 'Content-Type': 'application/json', @@ -135,7 +128,7 @@ export class CatalogClient implements CatalogApi { async removeEntityByUid(uid: string): Promise { const response = await fetch( - `${this.apiOrigin}${this.basePath}/entities/by-uid/${uid}`, + `${await this.discoveryApi.getBaseUrl('catalog')}/entities/by-uid/${uid}`, { method: 'DELETE', }, diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx new file mode 100644 index 0000000000..bf69818995 --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { AboutCard } from './AboutCard'; + +describe('', () => { + it('renders info and "view source" link', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'backstage.io/managed-by-location': + 'github:https://github.com/spotify/backstage/blob/master/software.yaml', + }, + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const { getByText } = render(); + expect(getByText('service')).toBeInTheDocument(); + expect(getByText('View Source').closest('a')).toHaveAttribute( + 'href', + 'https://github.com/spotify/backstage/blob/master/software.yaml', + ); + }); +}); diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx new file mode 100644 index 0000000000..939449f719 --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -0,0 +1,188 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Grid, + Typography, + makeStyles, + Chip, + IconButton, + Card, + CardContent, + CardHeader, + Divider, +} from '@material-ui/core'; +import { Entity } from '@backstage/catalog-model'; + +import GitHubIcon from '@material-ui/icons/GitHub'; +import { IconLinkVertical } from './IconLinkVertical'; +import EditIcon from '@material-ui/icons/Edit'; +import DocsIcon from '@material-ui/icons/Description'; + +const useStyles = makeStyles(theme => ({ + links: { + margin: theme.spacing(2, 0), + display: 'grid', + gridAutoFlow: 'column', + gridAutoColumns: 'min-content', + gridGap: theme.spacing(3), + }, + label: { + color: theme.palette.text.secondary, + textTransform: 'uppercase', + fontSize: '10px', + fontWeight: 'bold', + letterSpacing: 0.5, + overflow: 'hidden', + whiteSpace: 'nowrap', + }, + value: { + fontWeight: 'bold', + overflow: 'hidden', + lineHeight: '24px', + wordBreak: 'break-word', + }, + description: { + wordBreak: 'break-word', + }, +})); + +const iconMap: Record = { + github: , +}; + +type CodeLinkInfo = { icon?: React.ReactNode; href?: string }; + +function getCodeLinkInfo(entity: Entity): CodeLinkInfo { + const location = + entity?.metadata?.annotations?.['backstage.io/managed-by-location']; + + if (location) { + // split by first `:` + // e.g. "github:https://github.com/spotify/backstage/blob/master/software.yaml" + const [type, target] = location.split(/:(.+)/); + + return { icon: iconMap[type], href: target }; + } + return {}; +} + +type AboutCardProps = { + entity: Entity; +}; + +export function AboutCard({ entity }: AboutCardProps) { + const classes = useStyles(); + const codeLink = getCodeLinkInfo(entity); + + return ( + + + + + } + subheader={ + + } + /> + + + + + + {entity?.metadata?.description || 'No description'} + + + + + + + {(entity?.metadata?.tags || []).map(t => ( + + ))} + + + + + ); +} + +function AboutField({ + label, + value, + gridSizes, + children, +}: { + label: string; + value?: string; + gridSizes?: Record; + children?: React.ReactNode; +}) { + const classes = useStyles(); + + // Content is either children or a string prop `value` + const content = React.Children.count(children) ? ( + children + ) : ( + + {value || `unknown`} + + ); + return ( + + + {label} + + {content} + + ); +} diff --git a/plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx b/plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx new file mode 100644 index 0000000000..580dcff0f5 --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/IconLinkVertical/IconLinkVertical.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as React from 'react'; +import classnames from 'classnames'; +import { makeStyles, Link } from '@material-ui/core'; +import LinkIcon from '@material-ui/icons/Link'; + +export type IconLinkVerticalProps = { + icon?: React.ReactNode; + href?: string; + disabled?: boolean; + label: string; +}; + +const useIconStyles = makeStyles({ + link: { + display: 'grid', + justifyItems: 'center', + gridGap: 4, + textAlign: 'center', + }, + disabled: { + color: 'gray', + }, + label: { + fontSize: '0.7rem', + textTransform: 'uppercase', + fontWeight: 600, + letterSpacing: 1.2, + }, +}); + +export function IconLinkVertical({ + icon = , + href = '#', + disabled = false, + ...props +}: IconLinkVerticalProps) { + const classes = useIconStyles(); + + if (disabled) { + return ( + + {icon} + {props.label} + + ); + } + + return ( + + {icon} + {props.label} + + ); +} diff --git a/plugins/catalog/src/components/AboutCard/IconLinkVertical/index.ts b/plugins/catalog/src/components/AboutCard/IconLinkVertical/index.ts new file mode 100644 index 0000000000..ddc146dbd8 --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/IconLinkVertical/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { IconLinkVertical } from './IconLinkVertical'; diff --git a/plugins/catalog/src/components/AboutCard/index.ts b/plugins/catalog/src/components/AboutCard/index.ts new file mode 100644 index 0000000000..93765ff942 --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AboutCard } from './AboutCard'; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx index 39b3c66013..92f51ace06 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx @@ -31,12 +31,13 @@ type Props = { const CatalogLayout = ({ children }: Props) => { const greeting = getTimeBasedGreeting(); + const profile = useApi(identityApiRef).getProfile(); const userId = useApi(identityApiRef).getUserId(); return (
{ getLocationByEntity: () => Promise.resolve({ id: 'id', type: 'github', target: 'url' }), }; + const testProfile: Partial = { + displayName: 'Display Name', + }; const indentityApi: Partial = { getUserId: () => 'tools@example.com', + getProfile: () => testProfile, }; const renderWrapped = (children: React.ReactNode) => diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index e58b2d407b..db490cf542 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -15,23 +15,26 @@ */ import { + configApiRef, Content, ContentHeader, + errorApiRef, identityApiRef, SupportButton, - configApiRef, useApi, } from '@backstage/core'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; import { Button, makeStyles } from '@material-ui/core'; import SettingsIcon from '@material-ui/icons/Settings'; import StarIcon from '@material-ui/icons/Star'; -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; +import { catalogApiRef } from '../../api/types'; import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; import { useStarredEntities } from '../../hooks/useStarredEntites'; -import { CatalogFilter, ButtonGroup } from '../CatalogFilter/CatalogFilter'; +import { ButtonGroup, CatalogFilter } from '../CatalogFilter/CatalogFilter'; import { CatalogTable } from '../CatalogTable/CatalogTable'; +import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; import CatalogLayout from './CatalogLayout'; import { CatalogTabs, LabeledComponentType } from './CatalogTabs'; import { WelcomeBanner } from './WelcomeBanner'; @@ -47,13 +50,37 @@ const useStyles = makeStyles(theme => ({ const CatalogPageContents = () => { const styles = useStyles(); - const { loading, error, matchingEntities } = useFilteredEntities(); + const { + loading, + error, + reload, + matchingEntities, + availableTags, + } = useFilteredEntities(); + const configApi = useApi(configApiRef); + const catalogApi = useApi(catalogApiRef); + const errorApi = useApi(errorApiRef); const { isStarredEntity } = useStarredEntities(); const userId = useApi(identityApiRef).getUserId(); const [selectedTab, setSelectedTab] = useState(); const [selectedSidebarItem, setSelectedSidebarItem] = useState(); - const orgName = - useApi(configApiRef).getOptionalString('organization.name') ?? 'Company'; + const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; + + const addMockData = useCallback(async () => { + try { + const promises: Promise[] = []; + const root = configApi.getConfig('catalog.exampleEntityLocations'); + for (const type of root.keys()) { + for (const target of root.getStringArray(type)) { + promises.push(catalogApi.addLocation(type, target)); + } + } + await Promise.all(promises); + await reload(); + } catch (err) { + errorApi.post(err); + } + }, [catalogApi, configApi, errorApi, reload]); const tabs = useMemo( () => [ @@ -140,12 +167,14 @@ const CatalogPageContents = () => { onChange={({ label }) => setSelectedSidebarItem(label)} initiallySelected="owned" /> +
diff --git a/plugins/catalog/src/components/CatalogPage/index.ts b/plugins/catalog/src/components/CatalogPage/index.ts new file mode 100644 index 0000000000..1adb8866c8 --- /dev/null +++ b/plugins/catalog/src/components/CatalogPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { CatalogPage } from './CatalogPage'; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 707213f3bb..d1eef2a005 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -15,12 +15,11 @@ */ import { Entity } from '@backstage/catalog-model'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { render } from '@testing-library/react'; +import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; import * as React from 'react'; import { CatalogTable } from './CatalogTable'; -const entites: Entity[] = [ +const entities: Entity[] = [ { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', @@ -40,13 +39,14 @@ const entites: Entity[] = [ describe('CatalogTable component', () => { it('should render error message when error is passed in props', async () => { - const rendered = render( + const rendered = await renderWithEffects( wrapInTestApp( {}} />, ), ); @@ -57,12 +57,13 @@ describe('CatalogTable component', () => { }); it('should display entity names when loading has finished and no error occurred', async () => { - const rendered = render( + const rendered = await renderWithEffects( wrapInTestApp( {}} />, ), ); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index b5afbcaa0b..cd4df13775 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -15,11 +15,12 @@ */ import { Entity, LocationSpec } from '@backstage/catalog-model'; import { Table, TableColumn, TableProps } from '@backstage/core'; -import { Link } from '@material-ui/core'; +import { Chip, Link } from '@material-ui/core'; +import Add from '@material-ui/icons/Add'; import Edit from '@material-ui/icons/Edit'; import GitHub from '@material-ui/icons/GitHub'; import { Alert } from '@material-ui/lab'; -import React from 'react'; +import React, { Dispatch } from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; import { findLocationForEntityMeta } from '../../data/utils'; import { useStarredEntities } from '../../hooks/useStarredEntites'; @@ -45,6 +46,7 @@ const columns: TableColumn[] = [ .filter(Boolean) .join(':'), kind: entity.kind, + selectedTabId: 'overview', })} > {entity.metadata.name} @@ -63,6 +65,21 @@ const columns: TableColumn[] = [ title: 'Description', field: 'metadata.description', }, + { + title: 'Tags', + field: 'metadata.tags', + cellStyle: { + padding: '0px 16px 0px 20px', + }, + render: (entity: Entity) => ( + <> + {entity.metadata.tags && + entity.metadata.tags.map(t => ( + + ))} + + ), + }, ]; type CatalogTableProps = { @@ -70,6 +87,7 @@ type CatalogTableProps = { titlePreamble: string; loading: boolean; error?: any; + onAddMockData: Dispatch; }; export const CatalogTable = ({ @@ -77,6 +95,7 @@ export const CatalogTable = ({ loading, error, titlePreamble, + onAddMockData, }: CatalogTableProps) => { const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); @@ -132,6 +151,13 @@ export const CatalogTable = ({ onClick: () => toggleStarredEntity(rowData), }; }, + { + icon: () => , + tooltip: 'Add example components', + isFreeAction: true, + onClick: onAddMockData, + hidden: !(entities && entities.length === 0), + }, ]; return ( diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx deleted file mode 100644 index 1b966c0bde..0000000000 --- a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -jest.mock('react-router-dom', () => { - const actual = jest.requireActual('react-router-dom'); - const mockNavigate = jest.fn(); - return { - ...actual, - useNavigate: jest.fn(() => mockNavigate), - useParams: jest.fn(), - }; -}); - -import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { Entity } from '@backstage/catalog-model'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { render, waitFor } from '@testing-library/react'; -import * as React from 'react'; -import { CatalogApi, catalogApiRef } from '../../api/types'; -import { EntityPage, getPageTheme } from './EntityPage'; - -const { - useParams, - useNavigate, -}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock( - 'react-router-dom', -); - -const errorApi = { post: () => {} }; - -describe('EntityPage', () => { - it('should redirect to catalog page when name is not provided', async () => { - useParams.mockReturnValue({ - kind: 'Component', - optionalNamespaceAndName: '', - }); - - render( - wrapInTestApp( - ) as CatalogApi, - ], - ])} - > - - , - ), - ); - - await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog')); - }); -}); - -describe('getPageTheme', () => { - const defaultPageTheme = getPageTheme(); - it.each(['service', 'app', 'library', 'tool', 'documentation', 'website'])( - 'should select right theme for predefined type: %p ̰ ', - type => { - const theme = getPageTheme(({ - spec: { - type, - }, - } as any) as Entity); - expect(theme).toBeDefined(); - expect(theme).not.toBe(defaultPageTheme); - }, - ); - - it('should select default theme for unknown/unspecified types', () => { - const theme1 = getPageTheme(({ - spec: { - type: 'unknown-type', - }, - } as any) as Entity); - const theme2 = getPageTheme(({ - spec: {}, - } as any) as Entity); - expect(theme1).toBe(defaultPageTheme); - expect(theme2).toBe(defaultPageTheme); - }); -}); diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.tsx deleted file mode 100644 index 5dd35bf8db..0000000000 --- a/plugins/catalog/src/components/EntityPage/EntityPage.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Entity } from '@backstage/catalog-model'; -import { - Content, - errorApiRef, - Header, - HeaderLabel, - HeaderTabs, - Page, - pageTheme, - PageTheme, - Progress, - useApi, -} from '@backstage/core'; -import { SentryIssuesWidget } from '@backstage/plugin-sentry'; -import { Widget as GithubActionsWidget } from '@backstage/plugin-github-actions'; -import { - JenkinsBuildsWidget, - JenkinsLastBuildWidget, -} from '@backstage/plugin-jenkins'; -import { Grid, Box } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import React, { FC, useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { useAsync } from 'react-use'; -import { catalogApiRef } from '../..'; -import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; -import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard'; -import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; -import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; - -const REDIRECT_DELAY = 1000; -function headerProps( - kind: string, - namespace: string | undefined, - name: string, - entity: Entity | undefined, -): { headerTitle: string; headerType: string } { - return { - headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`, - headerType: (() => { - let t = kind.toLowerCase(); - if (entity && entity.spec && 'type' in entity.spec) { - t += ' — '; - t += (entity.spec as { type: string }).type.toLowerCase(); - } - return t; - })(), - }; -} - -export const getPageTheme = (entity?: Entity): PageTheme => { - const themeKey = entity?.spec?.type?.toString() ?? 'home'; - return pageTheme[themeKey] ?? pageTheme.home; -}; - -const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({ - entity, - title, -}) => ( - - {title} - {entity && } - -); - -export const EntityPage: FC<{}> = () => { - const { optionalNamespaceAndName, kind } = useParams() as { - optionalNamespaceAndName: string; - kind: string; - }; - const navigate = useNavigate(); - const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); - - const errorApi = useApi(errorApiRef); - const catalogApi = useApi(catalogApiRef); - - const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); - const { value: entity, error, loading } = useAsync( - () => catalogApi.getEntityByName({ kind, namespace, name }), - [catalogApi, kind, namespace, name], - ); - - useEffect(() => { - if (!error && !loading && !entity) { - errorApi.post(new Error('Entity not found!')); - setTimeout(() => { - navigate('/'); - }, REDIRECT_DELAY); - } - }, [errorApi, navigate, error, loading, entity]); - - if (!name) { - navigate('/catalog'); - return null; - } - - const cleanUpAfterRemoval = async () => { - setConfirmationDialogOpen(false); - navigate('/'); - }; - - const showRemovalDialog = () => setConfirmationDialogOpen(true); - - // TODO - Replace with proper tabs implementation - const tabs = [ - { - id: 'overview', - label: 'Overview', - }, - { - id: 'ci', - label: 'CI/CD', - }, - { - id: 'tests', - label: 'Tests', - }, - { - id: 'api', - label: 'API', - }, - { - id: 'monitoring', - label: 'Monitoring', - }, - { - id: 'quality', - label: 'Quality', - }, - ]; - - const { headerTitle, headerType } = headerProps( - kind, - namespace, - name, - entity, - ); - - return ( - -
} - pageTitleOverride={headerTitle} - type={headerType} - > - {entity && ( - <> - - - - - )} -
- - {loading && } - - {error && ( - - {error.toString()} - - )} - - {entity && ( - <> - - - - - - - - {entity.metadata?.annotations?.[ - 'backstage.io/jenkins-github-folder' - ] && ( - - - - )} - {entity.metadata?.annotations?.[ - 'backstage.io/jenkins-github-folder' - ] && ( - - - - )} - {entity.metadata?.annotations?.[ - 'backstage.io/github-actions-id' - ] && ( - - - - )} - - - - - - - setConfirmationDialogOpen(false)} - /> - - )} -
- ); -}; diff --git a/plugins/catalog/src/components/EntityPageApi/EntityPageApi.tsx b/plugins/catalog/src/components/EntityPageApi/EntityPageApi.tsx deleted file mode 100644 index b115539366..0000000000 --- a/plugins/catalog/src/components/EntityPageApi/EntityPageApi.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model'; -import { Content, Progress, useApi } from '@backstage/core'; -import { ApiDefinitionCard } from '@backstage/plugin-api-docs'; -import { Grid } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import React, { FC } from 'react'; -import { useAsync } from 'react-use'; -import { catalogApiRef } from '../..'; - -export const EntityPageApi: FC<{ entity: Entity }> = ({ entity }) => { - const catalogApi = useApi(catalogApiRef); - - const { value: apiEntities, loading } = useAsync(async () => { - const a = await Promise.all( - ((entity?.spec?.implementedApis as string[]) || []).map(api => - catalogApi.getEntityByName({ - kind: 'API', - name: api, - }), - ), - ); - const b = new Map(); - - a.filter(api => !!api).forEach(api => { - b.set(api?.metadata?.name!, api as ApiEntityV1alpha1); - }); - return b; - }, [catalogApi, entity]); - - return ( - - {loading && } - {!loading && ( - - {((entity?.spec?.implementedApis as string[]) || []).map(api => { - const apiEntity = apiEntities && apiEntities.get(api); - - return ( - - {!apiEntity && ( - - Error on fetching the API: {api} - - )} - - {apiEntity && ( - - )} - - ); - })} - - )} - - ); -}; diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx new file mode 100644 index 0000000000..6a1b80150e --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -0,0 +1,142 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useState, useContext } from 'react'; +import { useParams, useNavigate } from 'react-router'; + +import { EntityContext } from '../../hooks/useEntity'; +import { + pageTheme, + PageTheme, + Page, + Header, + HeaderLabel, + Content, + Progress, +} from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; +import { Box } from '@material-ui/core'; +import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; +import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; +import { Alert } from '@material-ui/lab'; +import { Tabbed } from './Tabbed'; + +const getPageTheme = (entity?: Entity): PageTheme => { + const themeKey = entity?.spec?.type?.toString() ?? 'home'; + return pageTheme[themeKey] ?? pageTheme.home; +}; + +const EntityPageTitle = ({ + entity, + title, +}: { + title: string; + entity: Entity | undefined; +}) => ( + + {title} + {entity && } + +); + +function headerProps( + kind: string, + namespace: string | undefined, + name: string, + entity: Entity | undefined, +): { headerTitle: string; headerType: string } { + return { + headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`, + headerType: (() => { + let t = kind.toLowerCase(); + if (entity && entity.spec && 'type' in entity.spec) { + t += ' — '; + t += (entity.spec as { type: string }).type.toLowerCase(); + } + return t; + })(), + }; +} + +export const EntityPageLayout = ({ + children, +}: { + children?: React.ReactNode; +}) => { + const { optionalNamespaceAndName, kind } = useParams() as { + optionalNamespaceAndName: string; + kind: string; + }; + const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); + + const { entity, loading, error } = useContext(EntityContext); + const { headerTitle, headerType } = headerProps( + kind, + namespace, + name, + entity!, + ); + + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + const navigate = useNavigate(); + const cleanUpAfterRemoval = async () => { + setConfirmationDialogOpen(false); + navigate('/'); + }; + + const showRemovalDialog = () => setConfirmationDialogOpen(true); + + return ( + +
} + pageTitleOverride={headerTitle} + type={headerType} + > + {entity && ( + <> + + + + + )} +
+ + {loading && } + + {entity && {children}} + + {error && ( + + {error.toString()} + + )} + setConfirmationDialogOpen(false)} + /> +
+ ); +}; +EntityPageLayout.Content = Tabbed.Content; diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx new file mode 100644 index 0000000000..d5ca9170a2 --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx @@ -0,0 +1,198 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Tabbed } from './Tabbed'; +import { renderInTestApp } from '@backstage/test-utils'; +import { fireEvent } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { Routes, Route } from 'react-router'; + +describe('Tabbed layout', () => { + it('renders simplest case', async () => { + const rendered = await renderInTestApp( + + tabbed-test-content} + /> + , + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); + }); + + it('throws if any other component is a child of Tabbed.Layout', async () => { + await expect( + renderInTestApp( + + tabbed-test-content} + /> +
This will cause app to throw
+
, + ), + ).rejects.toThrow(/This component only accepts/); + }); + + it('navigates when user clicks different tab', async () => { + const rendered = await renderInTestApp( + + + tabbed-test-content} + /> + tabbed-test-content-2} + /> + + } + /> + , + ); + + const secondTab = rendered.queryAllByRole('tab')[1]; + act(() => { + fireEvent.click(secondTab); + }); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument(); + + expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + }); + + describe('correctly delegates nested links', () => { + const renderRoute = (route: string) => + renderInTestApp( + + + tabbed-test-content} + /> + + tabbed-test-content-2 + + tabbed-test-nested-content-2} + /> + + + } + /> + + } + /> + , + { routeEntries: [route] }, + ); + + it('works for nested content', async () => { + const rendered = await renderRoute('/some-other-path/nested'); + + expect( + rendered.queryByText('tabbed-test-content'), + ).not.toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + expect( + rendered.queryByText('tabbed-test-nested-content-2'), + ).toBeInTheDocument(); + }); + + it('works for non-nested content', async () => { + const rendered = await renderRoute('/some-other-path/'); + + expect( + rendered.queryByText('tabbed-test-content'), + ).not.toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + expect( + rendered.queryByText('tabbed-test-nested-content-2'), + ).not.toBeInTheDocument(); + }); + }); + + it('shows only one tab contents at a time', async () => { + const rendered = await renderInTestApp( + + tabbed-test-content} + /> + tabbed-test-content-2} + /> + , + { routeEntries: ['/some-other-path'] }, + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument(); + + expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + }); + + it('redirects to the top level when no route is matching the url', async () => { + const rendered = await renderInTestApp( + + tabbed-test-content} + /> + tabbed-test-content-2} + /> + , + { routeEntries: ['/non-existing-path'] }, + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); + + expect( + rendered.queryByText('tabbed-test-content-2'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx new file mode 100644 index 0000000000..096b2fa708 --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { + useParams, + useNavigate, + PartialRouteObject, + matchRoutes, + RouteObject, + useRoutes, + Navigate, + RouteMatch, +} from 'react-router'; +import { Tab, HeaderTabs, Content } from '@backstage/core'; +import { Helmet } from 'react-helmet'; + +const getSelectedIndexOrDefault = ( + matchedRoute: RouteMatch, + tabs: Tab[], + defaultIndex = 0, +) => { + if (!matchedRoute) return defaultIndex; + const tabIndex = tabs.findIndex(t => t.id === matchedRoute.route.path); + return ~tabIndex ? tabIndex : defaultIndex; +}; + +/** + * Compound component, which allows you to define layout + * for EntityPage using Tabs as a subnavigation mechanism + * Constists of 2 parts: Tabbed.Layout and Tabbed.Content. + * Takes care of: tabs, routes, document titles, spacing around content + * + * @example + * ```jsx + * + * This is rendered under /example/anything-here route} + * /> + * + * ``` + */ +export const Tabbed = { + Layout: ({ children }: { children: React.ReactNode }) => { + const routes: PartialRouteObject[] = []; + const tabs: Tab[] = []; + const params = useParams(); + const navigate = useNavigate(); + + React.Children.forEach(children, child => { + if (!React.isValidElement(child)) { + // Skip conditionals resolved to falses/nulls/undefineds etc + return; + } + if (child.type !== Tabbed.Content) { + throw new Error( + 'This component only accepts Content elements as direct children. Check the code of the EntityPage.', + ); + } + const pathAndId = (child as JSX.Element).props.path; + + // Child here must be then always a functional component without any wrappers + tabs.push({ + id: pathAndId, + label: (child as JSX.Element).props.title, + }); + + routes.push({ + path: pathAndId, + element: child.props.element, + }); + }); + + // Add catch-all for incorrect sub-routes + if ((routes?.[0]?.path ?? '') !== '') + routes.push({ + path: '/*', + element: , + }); + + const [matchedRoute] = + matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? []; + const selectedIndex = getSelectedIndexOrDefault(matchedRoute, tabs); + const currentTab = tabs[selectedIndex]; + const title = currentTab?.label; + + const onTabChange = (index: number) => + // Remove trailing /* + // And remove leading / for relative navigation + // Note! route resolves relative to the position in the React tree, + // not relative to current location + navigate(tabs[index].id.replace(/\/\*$/, '').replace(/^\//, '')); + + const currentRouteElement = useRoutes(routes); + + if (!currentTab) return null; + return ( + <> + + + + {currentRouteElement} + + + ); + }, + Content: (_props: { path: string; title: string; element: JSX.Element }) => + null, +}; diff --git a/plugins/circleci/src/components/PluginHeader/index.ts b/plugins/catalog/src/components/EntityPageLayout/Tabbed/index.ts similarity index 94% rename from plugins/circleci/src/components/PluginHeader/index.ts rename to plugins/catalog/src/components/EntityPageLayout/Tabbed/index.ts index 4de972f6f2..d58cd626ba 100644 --- a/plugins/circleci/src/components/PluginHeader/index.ts +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './PluginHeader'; +export { Tabbed } from './Tabbed'; diff --git a/plugins/catalog/src/components/EntityPageLayout/index.ts b/plugins/catalog/src/components/EntityPageLayout/index.ts new file mode 100644 index 0000000000..acf6f948f1 --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { EntityPageLayout } from './EntityPageLayout'; diff --git a/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx b/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx deleted file mode 100644 index f4faa8c574..0000000000 --- a/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Entity } from '@backstage/catalog-model'; -import { Content } from '@backstage/core'; -import { SentryIssuesWidget } from '@backstage/plugin-sentry'; -import { Widget as GithubActionsWidget } from '@backstage/plugin-github-actions'; -import { JenkinsBuildsWidget, JenkinsLastBuildWidget, } from '@backstage/plugin-jenkins'; -import { Grid } from '@material-ui/core'; -import React, { FC } from 'react'; -import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard'; - -export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => { - return ( - - - - - - {entity.metadata?.annotations?.[ - 'backstage.io/jenkins-github-folder' - ] && ( - - - - )} - {entity.metadata?.annotations?.[ - 'backstage.io/jenkins-github-folder' - ] && ( - - - - )} - {entity.metadata?.annotations?.['backstage.io/github-actions-id'] && ( - - - - )} - - - - - - ); -}; diff --git a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx new file mode 100644 index 0000000000..e328b64d44 --- /dev/null +++ b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { ReactNode } from 'react'; +import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity'; + +export const EntityProvider = ({ children }: { children: ReactNode }) => { + const { entity, loading, error } = useEntityFromUrl(); + + return ( + + {children} + + ); +}; diff --git a/plugins/catalog/src/components/EntityProvider/index.ts b/plugins/catalog/src/components/EntityProvider/index.ts new file mode 100644 index 0000000000..01eae4737f --- /dev/null +++ b/plugins/catalog/src/components/EntityProvider/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { EntityProvider } from './EntityProvider'; diff --git a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx new file mode 100644 index 0000000000..6f294c54ff --- /dev/null +++ b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, + storageApiRef, +} from '@backstage/core'; +import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { CatalogApi, catalogApiRef } from '../../api/types'; +import { EntityFilterGroupsProvider } from '../../filter'; +import { ResultsFilter } from './ResultsFilter'; + +describe('Results Filter', () => { + const catalogApi: Partial = { + getEntities: () => + Promise.resolve([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity1', + tags: ['java'], + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity2', + }, + spec: { + owner: 'not-tools@example.com', + type: 'service', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity3', + tags: ['java', 'test'], + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, + }, + ] as Entity[]), + }; + + const indentityApi: Partial = { + getUserId: () => 'tools@example.com', + }; + + const renderWrapped = (children: React.ReactNode) => + render( + wrapInTestApp( + + {children}, + , + ), + ); + + it('should render all available tags', async () => { + const tags = ['test', 'java']; + const { findByText } = renderWrapped( + , + ); + for (const tag of tags) { + expect(await findByText(tag)).toBeInTheDocument(); + } + }); +}); diff --git a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.tsx b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.tsx new file mode 100644 index 0000000000..8c5737b7b6 --- /dev/null +++ b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.tsx @@ -0,0 +1,121 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Button, + Checkbox, + Divider, + List, + ListItem, + ListItemText, + makeStyles, + Theme, + Typography, +} from '@material-ui/core'; +import React, { useCallback, useContext, useState } from 'react'; +import { filterGroupsContext } from '../../filter/context'; + +const useStyles = makeStyles(theme => ({ + filterBox: { + display: 'flex', + margin: theme.spacing(2, 0, 0, 0), + }, + filterBoxTitle: { + margin: theme.spacing(1, 0, 0, 1), + fontWeight: 'bold', + flex: 1, + }, + title: { + margin: theme.spacing(1, 0, 0, 1), + textTransform: 'uppercase', + fontSize: 12, + fontWeight: 'bold', + }, + checkbox: { + padding: theme.spacing(0, 1, 0, 1), + }, +})); + +type Props = { + availableTags: string[]; +}; + +/** + * The additional results filter in the sidebar. + */ +export const ResultsFilter = ({ availableTags }: Props) => { + const classes = useStyles(); + + const [selectedTags, setSelectedTags] = useState([]); + const context = useContext(filterGroupsContext); + if (!context) { + throw new Error(`Must be used inside an EntityFilterGroupsProvider`); + } + const setSelectedTagsFilter = context?.setSelectedTags; + + const updateSelectedTags = useCallback( + (tags: string[]) => { + setSelectedTags(tags); + setSelectedTagsFilter(tags); + }, + [setSelectedTags, setSelectedTagsFilter], + ); + + return ( + <> +
+ + Refine Results + {' '} + +
+ + + Tags + + + {availableTags.map(t => { + const labelId = `checkbox-list-label-${t}`; + return ( + + updateSelectedTags( + selectedTags.includes(t) + ? selectedTags.filter(s => s !== t) + : [...selectedTags, t], + ) + } + > + + + + ); + })} + + + ); +}; diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx new file mode 100644 index 0000000000..61855f56d9 --- /dev/null +++ b/plugins/catalog/src/components/Router.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { ComponentType } from 'react'; +import { CatalogPage } from './CatalogPage'; +import { EntityPageLayout } from './EntityPageLayout'; +import { Route, Routes } from 'react-router'; +import { entityRoute, rootRoute } from '../routes'; +import { Content } from '@backstage/core'; +import { Typography, Link } from '@material-ui/core'; +import { EntityProvider } from './EntityProvider'; +import { useEntity } from '../hooks/useEntity'; + +const DefaultEntityPage = () => ( + + + This is default entity page. + + To override this component with your custom implementation, read + docs on{' '} + + backstage.io/docs + + + + } + /> + +); + +const EntityPageSwitch = ({ EntityPage }: { EntityPage: ComponentType }) => { + const { entity } = useEntity(); + // Loading and error states + if (!entity) return ; + + // Otherwise EntityPage provided from the App + // Note that EntityPage will include EntityPageLayout already + return ; +}; + +export const Router = ({ + EntityPage = DefaultEntityPage, +}: { + EntityPage?: ComponentType; +}) => ( + + } /> + + + + } + /> + +); diff --git a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx index dd557a4322..94bc428a45 100644 --- a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx +++ b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx @@ -16,8 +16,8 @@ import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; -import React, { useCallback, useRef, useState } from 'react'; -import { useAsync } from 'react-use'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useAsyncFn } from 'react-use'; import { catalogApiRef } from '../api/types'; import { filterGroupsContext, FilterGroupsContext } from './context'; import { @@ -46,7 +46,9 @@ export const EntityFilterGroupsProvider = ({ // The hook that implements the actual context building function useProvideEntityFilters(): FilterGroupsContext { const catalogApi = useApi(catalogApiRef); - const { value: entities, error } = useAsync(() => catalogApi.getEntities()); + const [{ value: entities, error }, doReload] = useAsyncFn(() => + catalogApi.getEntities(), + ); const filterGroups = useRef<{ [filterGroupId: string]: FilterGroup; @@ -54,16 +56,23 @@ function useProvideEntityFilters(): FilterGroupsContext { const selectedFilterKeys = useRef<{ [filterGroupId: string]: Set; }>({}); + const selectedTags = useRef([]); const [filterGroupStates, setFilterGroupStates] = useState<{ [filterGroupId: string]: FilterGroupStates; }>({}); const [matchingEntities, setMatchingEntities] = useState([]); + const [availableTags, setAvailableTags] = useState([]); + + useEffect(() => { + doReload(); + }, [doReload]); const rebuild = useCallback(() => { setFilterGroupStates( buildStates( filterGroups.current, selectedFilterKeys.current, + selectedTags.current, entities, error, ), @@ -72,9 +81,11 @@ function useProvideEntityFilters(): FilterGroupsContext { buildMatchingEntities( filterGroups.current, selectedFilterKeys.current, + selectedTags.current, entities, ), ); + setAvailableTags(collectTags(entities)); }, [entities, error]); const register = useCallback( @@ -109,14 +120,29 @@ function useProvideEntityFilters(): FilterGroupsContext { [rebuild], ); + const setSelectedTags = useCallback( + (tags: string[]) => { + selectedTags.current = tags; + rebuild(); + }, + [rebuild], + ); + + const reload = useCallback(async () => { + await doReload(); + }, [doReload]); + return { register, unregister, setGroupSelectedFilters, + setSelectedTags, + reload, loading: !error && !entities, error, filterGroupStates, matchingEntities, + availableTags, }; } @@ -125,6 +151,7 @@ function useProvideEntityFilters(): FilterGroupsContext { function buildStates( filterGroups: { [filterGroupId: string]: FilterGroup }, selectedFilterKeys: { [filterGroupId: string]: Set }, + selectedTags: string[], entities?: Entity[], error?: Error, ): { [filterGroupId: string]: FilterGroupStates } { @@ -153,6 +180,7 @@ function buildStates( const otherMatchingEntities = buildMatchingEntities( filterGroups, selectedFilterKeys, + selectedTags, entities, filterGroupId, ); @@ -170,11 +198,23 @@ function buildStates( return result; } +// Given all entites, find all possible tags and provide them in a sorted list. +function collectTags(entities?: Entity[]): string[] { + const tags = new Set(); + (entities || []).forEach(e => { + if (e.metadata.tags) { + e.metadata.tags.forEach(t => tags.add(t)); + } + }); + return Array.from(tags).sort(); +} + // Given all filter groups and what filters are actually selected, extract all // entities that match all those filter groups. function buildMatchingEntities( filterGroups: { [filterGroupId: string]: FilterGroup }, selectedFilterKeys: { [filterGroupId: string]: Set }, + selectedTags: string[], entities?: Entity[], excludeFilterGroupId?: string, ): Entity[] { @@ -201,6 +241,16 @@ function buildMatchingEntities( } } + // Filter by tags, if at least one tag is selected. Include all entities + // that have at least one of the selected tags + if (selectedTags.length > 0) { + allFilters.push( + entity => + !!entity.metadata.tags && + entity.metadata.tags.some(t => selectedTags.includes(t)), + ); + } + // All filter groups that had any checked filters need to match. Note that // every() always returns true for an empty array. return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? []; diff --git a/plugins/catalog/src/filter/context.ts b/plugins/catalog/src/filter/context.ts index 31e9c91b97..838ec8d532 100644 --- a/plugins/catalog/src/filter/context.ts +++ b/plugins/catalog/src/filter/context.ts @@ -26,10 +26,13 @@ export type FilterGroupsContext = { ) => void; unregister: (filterGroupId: string) => void; setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void; + setSelectedTags: (tags: string[]) => void; + reload: () => Promise; loading: boolean; error?: Error; filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; matchingEntities: Entity[]; + availableTags: string[]; }; /** diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx index 60e7d4f412..acf4790ebf 100644 --- a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx +++ b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx @@ -51,12 +51,12 @@ describe('useEntityFilterGroup', () => { it('works for an empty set of filters', async () => { catalogApi.getEntities.mockResolvedValue([]); const group: FilterGroup = { filters: {} }; - const { result, wait } = renderHook( + const { result, waitFor } = renderHook( () => useEntityFilterGroup('g1', group), { wrapper }, ); - await wait(() => expect(result.current.state.type).toBe('ready')); + await waitFor(() => expect(result.current.state.type).toBe('ready')); }); it('works for a single group', async () => { @@ -73,12 +73,12 @@ describe('useEntityFilterGroup', () => { f2: e => e.metadata.name !== 'n', }, }; - const { result, wait } = renderHook( + const { result, waitFor } = renderHook( () => useEntityFilterGroup('g1', group), { wrapper }, ); - await wait(() => expect(result.current.state.type).toEqual('ready')); + await waitFor(() => expect(result.current.state.type).toEqual('ready')); let state = result.current.state as FilterGroupStatesReady; expect(state.state.filters.f1).toEqual({ isSelected: false, @@ -91,7 +91,7 @@ describe('useEntityFilterGroup', () => { act(() => result.current.setSelectedFilters(['f1'])); - await wait(() => expect(result.current.state.type).toEqual('ready')); + await waitFor(() => expect(result.current.state.type).toEqual('ready')); state = result.current.state as FilterGroupStatesReady; expect(state.state.filters.f1).toEqual({ isSelected: true, @@ -104,7 +104,7 @@ describe('useEntityFilterGroup', () => { act(() => result.current.setSelectedFilters(['f2'])); - await wait(() => expect(result.current.state.type).toEqual('ready')); + await waitFor(() => expect(result.current.state.type).toEqual('ready')); state = result.current.state as FilterGroupStatesReady; expect(state.state.filters.f1).toEqual({ isSelected: false, diff --git a/plugins/catalog/src/filter/useFilteredEntities.ts b/plugins/catalog/src/filter/useFilteredEntities.ts index 256f5247c7..138a7547c0 100644 --- a/plugins/catalog/src/filter/useFilteredEntities.ts +++ b/plugins/catalog/src/filter/useFilteredEntities.ts @@ -30,5 +30,7 @@ export function useFilteredEntities() { loading: context.loading, error: context.error, matchingEntities: context.matchingEntities, + availableTags: context.availableTags, + reload: context.reload, }; } diff --git a/plugins/catalog/src/hooks/useEntity.ts b/plugins/catalog/src/hooks/useEntity.ts new file mode 100644 index 0000000000..53b1cff73a --- /dev/null +++ b/plugins/catalog/src/hooks/useEntity.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useEffect, createContext, useContext } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useApi, errorApiRef } from '@backstage/core'; +import { catalogApiRef } from '../api/types'; +import { useAsync } from 'react-use'; +import { Entity } from '@backstage/catalog-model'; + +const REDIRECT_DELAY = 2000; + +type EntityLoadingStatus = { + entity?: Entity; + loading: boolean; + error?: Error; +}; + +export const EntityContext = createContext({ + entity: undefined, + loading: true, + error: undefined, +}); + +export const useEntityFromUrl = (): EntityLoadingStatus => { + const { optionalNamespaceAndName, kind } = useParams(); + const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); + const navigate = useNavigate(); + const errorApi = useApi(errorApiRef); + const catalogApi = useApi(catalogApiRef); + + const { value: entity, error, loading } = useAsync( + () => catalogApi.getEntityByName({ kind, namespace, name }), + [catalogApi, kind, namespace, name], + ); + + useEffect(() => { + if (error || (!loading && !entity)) { + errorApi.post(new Error('Entity not found!')); + setTimeout(() => { + navigate('/'); + }, REDIRECT_DELAY); + } + + if (!name) { + errorApi.post(new Error('No name provided!')); + navigate('/'); + } + }, [errorApi, navigate, error, loading, entity, name]); + + return { entity, loading, error }; +}; + +/** + * Always going to return an entity, or throw an error if not a descendant of a EntityProvider. + */ +export const useEntity = () => { + const { entity } = useContext<{ entity: Entity }>(EntityContext as any); + return { entity }; +}; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 36fd69971d..a6d86e8102 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -19,3 +19,7 @@ export * from './api/CatalogClient'; export * from './api/types'; export * from './routes'; export { useEntityCompoundName } from './components/useEntityCompoundName'; +export { Router } from './components/Router'; +export { useEntity } from './hooks/useEntity'; +export { AboutCard } from './components/AboutCard'; +export { EntityPageLayout } from './components/EntityPageLayout'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 8f4ac80f48..dfe12fd500 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -14,15 +14,21 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; -import { CatalogPage } from './components/CatalogPage/CatalogPage'; -import { EntityPage } from './components/EntityPage/EntityPage'; -import { entityRoute, rootRoute } from './routes'; +import { + createPlugin, + createApiFactory, + discoveryApiRef, +} from '@backstage/core'; +import { catalogApiRef } from './api/types'; +import { CatalogClient } from './api/CatalogClient'; export const plugin = createPlugin({ id: 'catalog', - register({ router }) { - router.addRoute(rootRoute, CatalogPage); - router.addRoute(entityRoute, EntityPage); - }, + apis: [ + createApiFactory({ + api: catalogApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }), + }), + ], }); diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts index 69c4e651b4..14e23ff73b 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -20,11 +20,11 @@ const NoIcon = () => null; export const rootRoute = createRouteRef({ icon: NoIcon, - path: '/', + path: '', title: 'Catalog', }); export const entityRoute = createRouteRef({ icon: NoIcon, - path: '/catalog/:kind/:optionalNamespaceAndName/', + path: ':kind/:optionalNamespaceAndName/*', title: 'Entity', }); diff --git a/plugins/circleci/README.md b/plugins/circleci/README.md index 2dcc137a47..48e1bee6cc 100644 --- a/plugins/circleci/README.md +++ b/plugins/circleci/README.md @@ -4,8 +4,6 @@ Website: [https://circleci.com/](https://circleci.com/) - - ## Setup @@ -35,8 +33,39 @@ export default builder.build() as ApiHolder; export { plugin as Circleci } from '@backstage/plugin-circleci'; ``` -3. Run app with `yarn start` and navigate to `/circleci/settings` -4. Enter project settings and **project** token, acquired according to [https://circleci.com/docs/2.0/managing-api-tokens/](https://circleci.com/docs/2.0/managing-api-tokens/) +3. Register the plugin router: + +```jsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { Router as CircleCIRouter } from '@backstage/plugin-circleci'; + +// Then somewhere inside +} +/>; +``` + +4. Add proxy config: + +``` +// app-config.yaml +proxy: + '/circleci/api': + target: https://circleci.com/api/v1.1 + changeOrigin: true + pathRewrite: + '^/proxy/circleci/api/': '/' + headers: + Circle-Token: + $secret: + env: CIRCLECI_AUTH_TOKEN +``` + +5. Get and provide `CIRCLECI_AUTH_TOKEN` as env variable (https://circleci.com/docs/api/#add-an-api-token) +6. Add `circleci.com/project-slug` annotation to your component-info.yaml file in format // (https://backstage.io/docs/architecture-decisions/adrs-adr002#format) ## Features @@ -50,3 +79,4 @@ export { plugin as Circleci } from '@backstage/plugin-circleci'; ## Limitations - CircleCI has pretty strict rate limits per token, be careful with opened tabs +- CircelCI doesn't provide a way to auth by 3rd party (e.g. GitHub) token, nor by calling their OAuth endpoints, which currently stands in the way of better auth integration with Backstage (https://discuss.circleci.com/t/circleci-api-authorization-with-github-token/5356) diff --git a/plugins/circleci/dev/index.tsx b/plugins/circleci/dev/index.tsx index ed7dd5de9c..4bf67d5cb2 100644 --- a/plugins/circleci/dev/index.tsx +++ b/plugins/circleci/dev/index.tsx @@ -20,9 +20,9 @@ import { circleCIApiRef, CircleCIApi } from '../src/api'; createDevApp() .registerPlugin(plugin) - .registerApiFactory({ + .registerApi({ + api: circleCIApiRef, deps: {}, factory: () => new CircleCIApi(), - implements: circleCIApiRef, }) .render(); diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 4fb7a28124..7c8f36d370 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-circleci", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,11 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/plugin-catalog": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "circleci-api": "^4.0.0", @@ -36,8 +38,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/circleci/src/api/index.ts b/plugins/circleci/src/api/index.ts index 64a33e4c13..da4c811812 100644 --- a/plugins/circleci/src/api/index.ts +++ b/plugins/circleci/src/api/index.ts @@ -42,8 +42,8 @@ export class CircleCIApi { this.apiUrl = apiUrl; } - async retry(buildNumber: number, options: CircleCIOptions) { - return postBuildActions(options.token, buildNumber, BuildAction.RETRY, { + async retry(buildNumber: number, options: Partial) { + return postBuildActions('', buildNumber, BuildAction.RETRY, { circleHost: this.apiUrl, ...options.vcs, }); @@ -51,9 +51,9 @@ export class CircleCIApi { async getBuilds( { limit = 10, offset = 0 }: { limit: number; offset: number }, - options: CircleCIOptions, + options: Partial, ) { - return getBuildSummaries(options.token, { + return getBuildSummaries('', { options: { limit, offset, @@ -64,12 +64,12 @@ export class CircleCIApi { }); } - async getUser(options: CircleCIOptions) { - return getMe(options.token, { circleHost: this.apiUrl, ...options }); + async getUser(options: Partial) { + return getMe('', { circleHost: this.apiUrl, ...options }); } - async getBuild(buildNumber: number, options: CircleCIOptions) { - return getFullBuild(options.token, buildNumber, { + async getBuild(buildNumber: number, options: Partial) { + return getFullBuild('', buildNumber, { circleHost: this.apiUrl, ...options.vcs, }); diff --git a/plugins/circleci/src/assets/screenshot-1.png b/plugins/circleci/src/assets/screenshot-1.png index 2e3f1f420b..db99230a65 100644 Binary files a/plugins/circleci/src/assets/screenshot-1.png and b/plugins/circleci/src/assets/screenshot-1.png differ diff --git a/plugins/circleci/src/assets/screenshot-2.png b/plugins/circleci/src/assets/screenshot-2.png index 4e97cbcf8e..4f9ddcaec6 100644 Binary files a/plugins/circleci/src/assets/screenshot-2.png and b/plugins/circleci/src/assets/screenshot-2.png differ diff --git a/plugins/circleci/src/assets/screenshot-3.png b/plugins/circleci/src/assets/screenshot-3.png deleted file mode 100644 index ac20d58246..0000000000 Binary files a/plugins/circleci/src/assets/screenshot-3.png and /dev/null differ diff --git a/plugins/circleci/src/assets/screenshot-4.png b/plugins/circleci/src/assets/screenshot-4.png deleted file mode 100644 index 2d4ab5fe77..0000000000 Binary files a/plugins/circleci/src/assets/screenshot-4.png and /dev/null differ diff --git a/plugins/circleci/src/components/App.tsx b/plugins/circleci/src/components/App.tsx deleted file mode 100644 index a3f7112132..0000000000 --- a/plugins/circleci/src/components/App.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { Route, MemoryRouter, Routes } from 'react-router'; -import { BuildsPage, Builds } from '../pages/BuildsPage'; -import { DetailedViewPage, BuildWithSteps } from '../pages/BuildWithStepsPage'; -import { AppStateProvider } from '../state'; -import { Settings } from './Settings'; - -export const App = () => { - return ( - - <> - - } /> - } /> - - - - - ); -}; - -// TODO: allow pass in settings as props -// When some shared settings workflow -// will be established -export const CircleCIWidget = () => ( - - - <> - - } /> - } /> - - - - - -); diff --git a/plugins/circleci/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx similarity index 83% rename from plugins/circleci/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx rename to plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx index 77e6daf755..30994f5ce3 100644 --- a/plugins/circleci/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -15,18 +15,22 @@ */ import React, { FC, useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { Content, InfoCard, Progress } from '@backstage/core'; +import { InfoCard, Progress, Link } from '@backstage/core'; import { BuildWithSteps, BuildStepAction } from '../../api'; -import { Grid, Box, Link, IconButton } from '@material-ui/core'; +import { + Grid, + Box, + IconButton, + Breadcrumbs, + Typography, + Link as MaterialLink, +} from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import { PluginHeader } from '../../components/PluginHeader'; import { ActionOutput } from './lib/ActionOutput/ActionOutput'; -import { Layout } from '../../components/Layout'; import LaunchIcon from '@material-ui/icons/Launch'; -import { useSettings } from '../../state/useSettings'; import { useBuildWithSteps } from '../../state/useBuildWithSteps'; -const IconLink = IconButton as typeof Link; +const IconLink = (IconButton as any) as typeof MaterialLink; const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => ( #{build?.build_num} - {build?.subject} @@ -92,53 +96,13 @@ const pickClassName = ( return classes.neutral; }; -const Page = () => ( - - - - - -); - -const BuildWithStepsView: FC<{}> = () => { - const { buildId = '' } = useParams(); - const classes = useStyles(); - const [settings] = useSettings(); - const [{ loading, value }, { startPolling, stopPolling }] = useBuildWithSteps( - parseInt(buildId, 10), - ); - - useEffect(() => { - startPolling(); - return () => stopPolling(); - }, [buildId, settings, startPolling, stopPolling]); - - return ( - <> - - - - - } - cardClassName={classes.cardContent} - > - {loading ? : } - - - - - ); -}; - const BuildsList: FC<{ build?: BuildWithSteps }> = ({ build }) => ( {build && build.steps && build.steps.map( ({ name, actions }: { name: string; actions: BuildStepAction[] }) => ( - + ), )} @@ -162,5 +126,35 @@ const ActionsList: FC<{ actions: BuildStepAction[]; name: string }> = ({ ); }; -export default Page; -export { BuildWithStepsView as BuildWithSteps }; +export const BuildWithStepsPage = () => { + const { buildId = '' } = useParams(); + const classes = useStyles(); + const [{ loading, value }, { startPolling, stopPolling }] = useBuildWithSteps( + parseInt(buildId, 10), + ); + + useEffect(() => { + startPolling(); + return () => stopPolling(); + }, [buildId, startPolling, stopPolling]); + + return ( + <> + + All builds + Build details + + + + } + cardClassName={classes.cardContent} + > + {loading ? : } + + + + + ); +}; diff --git a/plugins/circleci/src/components/BuildWithStepsPage/index.ts b/plugins/circleci/src/components/BuildWithStepsPage/index.ts new file mode 100644 index 0000000000..c5627bda1c --- /dev/null +++ b/plugins/circleci/src/components/BuildWithStepsPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { BuildWithStepsPage } from './BuildWithStepsPage'; diff --git a/plugins/circleci/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx b/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx similarity index 84% rename from plugins/circleci/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx rename to plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx index dd526c1b5f..65e4537ad7 100644 --- a/plugins/circleci/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx +++ b/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx @@ -13,23 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect, useState, FC, Suspense } from 'react'; + import { - ExpansionPanel, - ExpansionPanelSummary, - Typography, - ExpansionPanelDetails, + Accordion, + AccordionDetails, + AccordionSummary, LinearProgress, + Typography, } from '@material-ui/core'; -import moment from 'moment'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { makeStyles } from '@material-ui/core/styles'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { BuildStepAction } from 'circleci-api'; +import moment from 'moment'; +import React, { FC, Suspense, useEffect, useState } from 'react'; const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); moment.relativeTimeThreshold('ss', 0); const useStyles = makeStyles({ - expansionPanelDetails: { + accordionDetails: { padding: 0, }, button: { @@ -66,11 +67,8 @@ export const ActionOutput: FC<{ ) .humanize(); return ( - - + } aria-controls={`panel-${name}-content`} id={`panel-${name}-header`} @@ -81,8 +79,8 @@ export const ActionOutput: FC<{ {name} ({timeElapsed}) - - + + {messages.length === 0 ? ( 'Nothing here...' ) : ( @@ -92,7 +90,7 @@ export const ActionOutput: FC<{ )} - - + + ); }; diff --git a/plugins/circleci/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts b/plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/index.ts similarity index 100% rename from plugins/circleci/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts rename to plugins/circleci/src/components/BuildWithStepsPage/lib/ActionOutput/index.ts diff --git a/plugins/circleci/src/components/BuildsPage/BuildsPage.tsx b/plugins/circleci/src/components/BuildsPage/BuildsPage.tsx new file mode 100644 index 0000000000..d8c38de481 --- /dev/null +++ b/plugins/circleci/src/components/BuildsPage/BuildsPage.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Builds } from './lib/Builds'; +import { Grid } from '@material-ui/core'; + +export const BuildsPage = () => ( + + + + + +); diff --git a/plugins/circleci/src/components/BuildsPage/index.ts b/plugins/circleci/src/components/BuildsPage/index.ts new file mode 100644 index 0000000000..f9543ed0a8 --- /dev/null +++ b/plugins/circleci/src/components/BuildsPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { BuildsPage } from './BuildsPage'; diff --git a/plugins/circleci/src/pages/BuildsPage/lib/Builds/Builds.tsx b/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx similarity index 100% rename from plugins/circleci/src/pages/BuildsPage/lib/Builds/Builds.tsx rename to plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx diff --git a/plugins/circleci/src/pages/BuildsPage/lib/Builds/index.ts b/plugins/circleci/src/components/BuildsPage/lib/Builds/index.ts similarity index 100% rename from plugins/circleci/src/pages/BuildsPage/lib/Builds/index.ts rename to plugins/circleci/src/components/BuildsPage/lib/Builds/index.ts diff --git a/plugins/circleci/src/pages/BuildsPage/lib/CITable/CITable.tsx b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx similarity index 93% rename from plugins/circleci/src/pages/BuildsPage/lib/CITable/CITable.tsx rename to plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx index b349dcfcbe..8e693429c2 100644 --- a/plugins/circleci/src/pages/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -17,7 +17,7 @@ import React, { FC } from 'react'; import { Link, Typography, Box, IconButton } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; -import { Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink, generatePath } from 'react-router-dom'; import { StatusError, StatusWarning, @@ -27,6 +27,7 @@ import { Table, TableColumn, } from '@backstage/core'; +import { circleCIBuildRouteRef } from '../../../../route-refs'; export type CITableBuildInfo = { id: string; @@ -80,7 +81,10 @@ const generatedColumns: TableColumn[] = [ field: 'buildName', highlight: true, render: (row: Partial) => ( - + {row.buildName} ), diff --git a/plugins/circleci/src/pages/BuildsPage/lib/CITable/index.ts b/plugins/circleci/src/components/BuildsPage/lib/CITable/index.ts similarity index 100% rename from plugins/circleci/src/pages/BuildsPage/lib/CITable/index.ts rename to plugins/circleci/src/components/BuildsPage/lib/CITable/index.ts diff --git a/plugins/circleci/src/components/PluginHeader/PluginHeader.tsx b/plugins/circleci/src/components/PluginHeader/PluginHeader.tsx deleted file mode 100644 index 9f94cff480..0000000000 --- a/plugins/circleci/src/components/PluginHeader/PluginHeader.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { FC } from 'react'; -import { Link as RouterLink, useLocation } from 'react-router-dom'; -import { ContentHeader, SupportButton } from '@backstage/core'; -import { Button, IconButton, Box, Typography } from '@material-ui/core'; -import ArrowBack from '@material-ui/icons/ArrowBack'; -import SettingsIcon from '@material-ui/icons/Settings'; -import { useSettings } from '../../state'; - -export type Props = { title?: string }; -export const PluginHeader: FC = ({ title = 'CircleCI' }) => { - const [, { showSettings }] = useSettings(); - const location = useLocation(); - const notRoot = !location.pathname.match(/\/circleci\/?$/); - const isSettingsPage = location.pathname.match(/\/circleci\/settings\/?/); - return ( - ( - - {notRoot && ( - - - - )} - {title} - - )} - > - {!isSettingsPage && ( - - )} - - This plugin allows you to view and interact with your builds within the - Circle CI environment. - - - ); -}; diff --git a/plugins/circleci/src/components/Router.tsx b/plugins/circleci/src/components/Router.tsx new file mode 100644 index 0000000000..282eac9111 --- /dev/null +++ b/plugins/circleci/src/components/Router.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Routes, Route } from 'react-router'; +import { circleCIRouteRef, circleCIBuildRouteRef } from '../route-refs'; +import { BuildWithStepsPage } from './BuildWithStepsPage/'; +import { BuildsPage } from './BuildsPage'; +import { CIRCLECI_ANNOTATION } from '../constants'; +import { Entity } from '@backstage/catalog-model'; +import { WarningPanel } from '@backstage/core'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[CIRCLECI_ANNOTATION]) && + entity.metadata.annotations?.[CIRCLECI_ANNOTATION] !== ''; + +export const Router = ({ entity }: { entity: Entity }) => + !isPluginApplicableToEntity(entity) ? ( + +
{CIRCLECI_ANNOTATION}
annotation is missing on the entity. +
+ ) : ( + + } /> + } + /> + + ); diff --git a/plugins/circleci/src/components/Settings/Settings.tsx b/plugins/circleci/src/components/Settings/Settings.tsx deleted file mode 100644 index b3b62ce930..0000000000 --- a/plugins/circleci/src/components/Settings/Settings.tsx +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { useState, useEffect } from 'react'; -import { - Button, - TextField, - List, - ListItem, - Snackbar, - Box, - Dialog, - DialogTitle, -} from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import { useSettings } from '../../state'; - -const Settings = () => { - const [ - { - repo: repoFromStore, - owner: ownerFromStore, - token: tokenFromStore, - showSettings, - }, - { saveSettings, hideSettings }, - ] = useSettings(); - - const [token, setToken] = useState(() => tokenFromStore); - const [owner, setOwner] = useState(() => ownerFromStore); - const [repo, setRepo] = useState(() => repoFromStore); - - useEffect(() => { - if (tokenFromStore !== token) { - setToken(token); - } - if (ownerFromStore !== owner) { - setOwner(owner); - } - if (repoFromStore !== repo) { - setRepo(repo); - } - }, [ownerFromStore, repoFromStore, tokenFromStore, token, owner, repo]); - - const [saved, setSaved] = useState(false); - - return ( - <> - setSaved(false)} - > - Credentials saved. - - - - Project Credentials - {/* {authed ? : } */} - - - - - setToken(e.target.value)} - /> - - - setOwner(e.target.value)} - /> - - - setRepo(e.target.value)} - /> - - - - - - - - - - - ); -}; - -export default Settings; diff --git a/plugins/circleci/src/constants.ts b/plugins/circleci/src/constants.ts new file mode 100644 index 0000000000..8c96db93e2 --- /dev/null +++ b/plugins/circleci/src/constants.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const CIRCLECI_ANNOTATION = 'circleci.com/project-slug'; diff --git a/plugins/circleci/src/index.ts b/plugins/circleci/src/index.ts index 11f2c80b88..c6cb140208 100644 --- a/plugins/circleci/src/index.ts +++ b/plugins/circleci/src/index.ts @@ -17,4 +17,5 @@ export { plugin } from './plugin'; export * from './api'; export * from './route-refs'; -export { CircleCIWidget } from './components/App'; +export { Router, isPluginApplicableToEntity } from './components/Router'; +export { CIRCLECI_ANNOTATION } from './constants'; diff --git a/plugins/circleci/src/pages/BuildsPage/BuildsPage.tsx b/plugins/circleci/src/pages/BuildsPage/BuildsPage.tsx deleted file mode 100644 index 7b124c03c1..0000000000 --- a/plugins/circleci/src/pages/BuildsPage/BuildsPage.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { FC } from 'react'; -import { Content } from '@backstage/core'; -import { Grid } from '@material-ui/core'; -import { Builds as BuildsComp } from './lib/Builds'; -import { Layout } from '../../components/Layout'; -import { PluginHeader } from '../../components/PluginHeader'; - -const BuildsPage: FC<{}> = () => ( - - - - - -); - -const Builds = () => ( - <> - - - - - - - -); - -export default BuildsPage; -export { Builds }; diff --git a/plugins/circleci/src/plugin.ts b/plugins/circleci/src/plugin.ts index 6a2e37df10..f966caa383 100644 --- a/plugins/circleci/src/plugin.ts +++ b/plugins/circleci/src/plugin.ts @@ -13,13 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createPlugin } from '@backstage/core'; -import { App } from './components/App'; -import { circleCIRouteRef } from './route-refs'; + +import { createPlugin, createApiFactory, configApiRef } from '@backstage/core'; +import { circleCIApiRef, CircleCIApi } from './api'; export const plugin = createPlugin({ id: 'circleci', - register({ router }) { - router.addRoute(circleCIRouteRef, App, { exact: false }); - }, + apis: [ + createApiFactory({ + api: circleCIApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + new CircleCIApi( + `${configApi.getString('backend.baseUrl')}/proxy/circleci/api`, + ), + }), + ], }); diff --git a/plugins/circleci/src/route-refs.tsx b/plugins/circleci/src/route-refs.tsx index d87ad2fbad..d6bad363ed 100644 --- a/plugins/circleci/src/route-refs.tsx +++ b/plugins/circleci/src/route-refs.tsx @@ -33,6 +33,11 @@ const CircleCIIcon: FC = props => ( export const circleCIRouteRef = createRouteRef({ icon: CircleCIIcon, - path: '/circleci', - title: 'CircleCI', + path: '', + title: 'CircleCI | All builds', +}); + +export const circleCIBuildRouteRef = createRouteRef({ + path: ':buildId', + title: 'CircleCI | Build info', }); diff --git a/plugins/circleci/src/state/AppState.tsx b/plugins/circleci/src/state/AppState.tsx deleted file mode 100644 index 2ee00362ee..0000000000 --- a/plugins/circleci/src/state/AppState.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { FC, useReducer, Dispatch, Reducer } from 'react'; -import { circleCIApiRef } from '../api'; -import type { State, Action, SettingsState } from './types'; - -export type { SettingsState }; - -export const AppContext = React.createContext<[State, Dispatch]>( - [] as any, -); -export const STORAGE_KEY = `${circleCIApiRef.id}.settings`; - -const initialState: State = { - owner: '', - repo: '', - token: '', - showSettings: false, -}; - -const reducer: Reducer = (state, action) => { - switch (action.type) { - case 'setCredentials': - return { - ...state, - ...action.payload, - }; - case 'showSettings': - return { ...state, showSettings: true }; - case 'hideSettings': - return { ...state, showSettings: false }; - default: - return state; - } -}; - -export const AppStateProvider: FC = ({ children }) => { - const [state, dispatch] = useReducer(reducer, initialState); - return ( - - <>{children} - - ); -}; diff --git a/plugins/circleci/src/state/index.ts b/plugins/circleci/src/state/index.ts index 2321103eb4..d21a380c2a 100644 --- a/plugins/circleci/src/state/index.ts +++ b/plugins/circleci/src/state/index.ts @@ -13,7 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './AppState'; -export * from './useSettings'; export * from './useBuilds'; export * from './useBuildWithSteps'; diff --git a/plugins/circleci/src/state/useBuildWithSteps.ts b/plugins/circleci/src/state/useBuildWithSteps.ts index 8fa7fa896d..c169b8fe63 100644 --- a/plugins/circleci/src/state/useBuildWithSteps.ts +++ b/plugins/circleci/src/state/useBuildWithSteps.ts @@ -14,31 +14,35 @@ * limitations under the License. */ import { errorApiRef, useApi } from '@backstage/core'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useAsyncRetry } from 'react-use'; -import { circleCIApiRef, GitType } from '../api/index'; +import { circleCIApiRef } from '../api/index'; import { useAsyncPolling } from './useAsyncPolling'; -import { useSettings } from './useSettings'; +import { useProjectSlugFromEntity, mapVcsType } from './useBuilds'; const INTERVAL_AMOUNT = 1500; export function useBuildWithSteps(buildId: number) { - const [{ token, repo, owner }] = useSettings(); + const { vcs, repo, owner } = useProjectSlugFromEntity(); const api = useApi(circleCIApiRef); const errorApi = useApi(errorApiRef); + const vcsOption = useMemo( + () => ({ + owner: owner, + repo: repo, + type: mapVcsType(vcs), + }), + [owner, repo, vcs], + ); + const getBuildWithSteps = useCallback(async () => { - if (owner === '' || repo === '' || token === '') { + if (owner === '' || repo === '' || vcs === '') { return Promise.reject('No credentials provided'); } try { const options = { - token: token, - vcs: { - owner: owner, - repo: repo, - type: GitType.GITHUB, - }, + vcs: vcsOption, }; const build = await api.getBuild(buildId, options); return Promise.resolve(build); @@ -46,17 +50,12 @@ export function useBuildWithSteps(buildId: number) { errorApi.post(e); return Promise.reject(e); } - }, [token, owner, repo, buildId, api, errorApi]); + }, [vcsOption, buildId, api, errorApi]); // eslint-disable-line react-hooks/exhaustive-deps const restartBuild = async () => { try { await api.retry(buildId, { - token: token, - vcs: { - owner: owner, - repo: repo, - type: GitType.GITHUB, - }, + vcs: vcsOption, }); } catch (e) { errorApi.post(e); diff --git a/plugins/circleci/src/state/useBuilds.ts b/plugins/circleci/src/state/useBuilds.ts index 6d38c5f901..2a4aed7fd6 100644 --- a/plugins/circleci/src/state/useBuilds.ts +++ b/plugins/circleci/src/state/useBuilds.ts @@ -18,8 +18,9 @@ import { BuildSummary, GitType } from 'circleci-api'; import { useCallback, useEffect, useState } from 'react'; import { useAsyncRetry } from 'react-use'; import { circleCIApiRef } from '../api/index'; -import type { CITableBuildInfo } from '../pages/BuildsPage/lib/CITable'; -import { useSettings } from './useSettings'; +import type { CITableBuildInfo } from '../components/BuildsPage/lib/CITable'; +import { useEntity } from '@backstage/plugin-catalog'; +import { CIRCLECI_ANNOTATION } from '../constants'; const makeReadableStatus = (status: string | undefined) => { if (!status) return ''; @@ -68,9 +69,25 @@ export const transform = ( }); }; -export function useBuilds() { - const [{ repo, owner, token }] = useSettings(); +export const useProjectSlugFromEntity = () => { + const { entity } = useEntity(); + const [vcs, owner, repo] = ( + entity.metadata.annotations?.[CIRCLECI_ANNOTATION] ?? '' + ).split('/'); + return { vcs, owner, repo }; +}; +export function mapVcsType(vcs: string): GitType { + switch (vcs) { + case 'github': + return GitType.GITHUB; + default: + return GitType.BITBUCKET; + } +} + +export function useBuilds() { + const { repo, owner, vcs } = useProjectSlugFromEntity(); const api = useApi(circleCIApiRef); const errorApi = useApi(errorApiRef); @@ -80,7 +97,7 @@ export function useBuilds() { const getBuilds = useCallback( async ({ limit, offset }: { limit: number; offset: number }) => { - if (owner === '' || repo === '' || token === '') { + if (owner === '' || repo === '' || vcs === '') { return Promise.reject('No credentials provided'); } @@ -88,11 +105,10 @@ export function useBuilds() { return await api.getBuilds( { limit, offset }, { - token: token, vcs: { owner: owner, repo: repo, - type: GitType.GITHUB, + type: mapVcsType(vcs), }, }, ); @@ -101,13 +117,12 @@ export function useBuilds() { return Promise.reject(e); } }, - [repo, token, owner, api, errorApi], + [repo, owner, vcs, api, errorApi], ); const restartBuild = async (buildId: number) => { try { await api.retry(buildId, { - token: token, vcs: { owner: owner, repo: repo, diff --git a/plugins/circleci/src/state/useSettings.ts b/plugins/circleci/src/state/useSettings.ts deleted file mode 100644 index 3cc58a65bd..0000000000 --- a/plugins/circleci/src/state/useSettings.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { errorApiRef, useApi } from '@backstage/core'; -import { useContext, useEffect } from 'react'; -import { AppContext, STORAGE_KEY } from './AppState'; -import { Settings } from './types'; - -export function useSettings() { - const [settings, dispatch] = useContext(AppContext); - - const errorApi = useApi(errorApiRef); - - useEffect(() => { - const rehydrate = () => { - try { - const stateFromStorage = JSON.parse( - sessionStorage.getItem(STORAGE_KEY)!, - ); - if ( - stateFromStorage && - Object.keys(stateFromStorage).some( - k => (settings as any)[k] !== stateFromStorage[k], - ) - ) - dispatch({ - type: 'setCredentials', - payload: stateFromStorage, - }); - } catch (error) { - errorApi.post(error); - } - }; - - rehydrate(); - }, [dispatch, errorApi, settings]); - - const persist = (state: Settings) => { - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); - }; - - return [ - settings, - { - saveSettings: (state: Settings) => { - persist(state); - dispatch({ - type: 'setCredentials', - payload: state, - }); - }, - showSettings: () => dispatch({ type: 'showSettings' }), - hideSettings: () => dispatch({ type: 'hideSettings' }), - }, - ] as const; -} diff --git a/plugins/explore/README.md b/plugins/explore/README.md index 12a4e58380..d5453e594c 100644 --- a/plugins/explore/README.md +++ b/plugins/explore/README.md @@ -1,4 +1,5 @@ # Title + Welcome to the explore plugin! ## Sub-section 1 diff --git a/plugins/explore/package.json b/plugins/explore/package.json index e7783675ae..f302f4080c 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,20 +21,21 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "classnames": "^2.2.6", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "react-use": "^15.3.3", + "react-router": "6.0.0-beta.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", - "@backstage/test-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/explore/src/components/ExploreCard.tsx b/plugins/explore/src/components/ExploreCard.tsx index af6eca61b7..9c61e9bea8 100644 --- a/plugins/explore/src/components/ExploreCard.tsx +++ b/plugins/explore/src/components/ExploreCard.tsx @@ -28,7 +28,7 @@ import { } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(theme => ({ card: { display: 'flex', flexDirection: 'column', diff --git a/plugins/explore/src/components/ExplorePluginPage.tsx b/plugins/explore/src/components/ExplorePluginPage.tsx index 3e073ad322..4365157f62 100644 --- a/plugins/explore/src/components/ExplorePluginPage.tsx +++ b/plugins/explore/src/components/ExplorePluginPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Typography } from '@material-ui/core'; import { Content, @@ -107,7 +107,7 @@ const toolsCards = [ }, ]; -const ExplorePluginPage: FC<{}> = () => { +export const ExplorePluginPage = () => { const classes = useStyles(); return ( @@ -130,5 +130,3 @@ const ExplorePluginPage: FC<{}> = () => { ); }; - -export default ExplorePluginPage; diff --git a/packages/core/src/layout/Header/Waves.test.tsx b/plugins/explore/src/components/Router.tsx similarity index 66% rename from packages/core/src/layout/Header/Waves.test.tsx rename to plugins/explore/src/components/Router.tsx index 76bf5e913e..becb2522d0 100644 --- a/packages/core/src/layout/Header/Waves.test.tsx +++ b/plugins/explore/src/components/Router.tsx @@ -15,13 +15,12 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import { pageTheme } from '../Page/PageThemeProvider'; -import { Waves } from './Waves'; +import { Route, Routes } from 'react-router'; +import { ExplorePluginPage } from './ExplorePluginPage'; +import { rootRouteRef } from '../plugin'; -describe('', () => { - it('should render svg', () => { - const rendered = render(); - rendered.getByTestId('wave-svg'); - }); -}); +export const Router = () => ( + + } /> + +); diff --git a/plugins/explore/src/index.ts b/plugins/explore/src/index.ts index 3a0a0fe2d3..ff7857cacd 100644 --- a/plugins/explore/src/index.ts +++ b/plugins/explore/src/index.ts @@ -15,3 +15,4 @@ */ export { plugin } from './plugin'; +export { Router } from './components/Router'; diff --git a/plugins/explore/src/plugin.ts b/plugins/explore/src/plugin.ts index 66e48a9b15..75ea892242 100644 --- a/plugins/explore/src/plugin.ts +++ b/plugins/explore/src/plugin.ts @@ -14,12 +14,9 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; -import ExplorePluginPage from './components/ExplorePluginPage'; +import { createPlugin, createRouteRef } from '@backstage/core'; +export const rootRouteRef = createRouteRef({ path: '', title: 'Explore' }); export const plugin = createPlugin({ id: 'explore', - register({ router }) { - router.registerRoute('/explore', ExplorePluginPage); - }, }); diff --git a/packages/create-app/templates/default-app/plugins/welcome/.eslintrc.js b/plugins/gcp-projects/.eslintrc.js similarity index 100% rename from packages/create-app/templates/default-app/plugins/welcome/.eslintrc.js rename to plugins/gcp-projects/.eslintrc.js diff --git a/plugins/gcp-projects/README.md b/plugins/gcp-projects/README.md new file mode 100644 index 0000000000..586cd7b603 --- /dev/null +++ b/plugins/gcp-projects/README.md @@ -0,0 +1,13 @@ +# gcp-projects + +Welcome to the gcp-projects plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/gcp-projects](http://localhost:3000/gcp-projects). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. diff --git a/plugins/gcp-projects/dev/index.tsx b/plugins/gcp-projects/dev/index.tsx new file mode 100644 index 0000000000..812a5585d4 --- /dev/null +++ b/plugins/gcp-projects/dev/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/packages/create-app/templates/default-app/plugins/welcome/package.json.hbs b/plugins/gcp-projects/package.json similarity index 59% rename from packages/create-app/templates/default-app/plugins/welcome/package.json.hbs rename to plugins/gcp-projects/package.json index 77e57c1a7c..cdc4bb507d 100644 --- a/packages/create-app/templates/default-app/plugins/welcome/package.json.hbs +++ b/plugins/gcp-projects/package.json @@ -1,10 +1,12 @@ { - "name": "plugin-welcome", - "version": "0.0.0", + "name": "@backstage/plugin-gcp-projects", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", + "license": "Apache-2.0", "private": true, "publishConfig": { + "access": "public", "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, @@ -19,20 +21,24 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^{{version}}", - "@backstage/theme": "^{{version}}", - "@material-ui/core": "^4.9.1", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3", - "react-router-dom": "6.0.0-beta.0" + "react-router-dom": "^5.2.0", + "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^{{version}}", - "@backstage/dev-utils": "^{{version}}", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, "files": [ diff --git a/plugins/gcp-projects/src/api/GCPApi.ts b/plugins/gcp-projects/src/api/GCPApi.ts new file mode 100644 index 0000000000..95b686815e --- /dev/null +++ b/plugins/gcp-projects/src/api/GCPApi.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; +import { Project, Operation } from './types'; + +export const GCPApiRef = createApiRef({ + id: 'plugin.gcpprojects.service', + description: 'Used by the GCP Projects plugin to make requests', +}); + +export type GCPApi = { + listProjects: ({ token }: { token: string }) => Promise; + getProject: (projectId: string, token: Promise) => Promise; + createProject: ( + projectName: string, + projectId: string, + owner: string, + token: string, + ) => Promise; +}; diff --git a/plugins/gcp-projects/src/api/GCPClient.ts b/plugins/gcp-projects/src/api/GCPClient.ts new file mode 100644 index 0000000000..dc4fe21132 --- /dev/null +++ b/plugins/gcp-projects/src/api/GCPClient.ts @@ -0,0 +1,124 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GCPApi } from './GCPApi'; +import { Project, Operation, Status } from './types'; + +const BaseURL = + 'https://content-cloudresourcemanager.googleapis.com/v1/projects'; + +export class GCPClient implements GCPApi { + async listProjects({ token }: { token: string }): Promise { + const response = await fetch(BaseURL, { + headers: new Headers({ + Accept: '*/*', + Authorization: `Bearer ${token}`, + }), + }); + + if (!response.ok) { + return [ + { + name: 'Error', + projectNumber: 'Response status is not OK', + projectId: 'Error', + lifecycleState: 'error', + createTime: 'Error', + }, + ]; + } + + const data = await response.json(); + + return data.projects; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getProject( + projectId: string, + token: Promise, + ): Promise { + const url = `${BaseURL}/${projectId}`; + const response = await fetch(url, { + headers: new Headers({ + Authorization: `Bearer ${await token}`, + }), + }); + + const dataBlank: Project = { + name: 'Error', + projectNumber: `Response status is ${response.status}`, + projectId: 'Error', + lifecycleState: 'error', + createTime: 'Error', + }; + + if (!response.ok) { + return dataBlank; + } + + const data = await response.json(); + + const newData: Project = data; + + return newData; + } + + async createProject( + projectName: string, + projectId: string, + token: string, + ): Promise { + const status: Status = { + code: 0, + message: '', + details: [], + }; + + const op: Operation = { + name: '', + metadata: '', + done: true, + error: status, + response: '', + }; + + const newProject: Project = { + name: projectName, + projectId: projectId, + }; + + const body = JSON.stringify(newProject); + + const response = await fetch(BaseURL, { + headers: new Headers({ + Accept: '*/*', + Authorization: `Bearer ${token}`, + }), + body: body, + method: 'POST', + }); + + if (!response.ok) { + status.code = response.status; + return op; + } + + const data = await response.json(); + + return data; + } +} diff --git a/plugins/gcp-projects/src/api/index.ts b/plugins/gcp-projects/src/api/index.ts new file mode 100644 index 0000000000..51f617f19c --- /dev/null +++ b/plugins/gcp-projects/src/api/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './GCPApi'; +export * from './GCPClient'; +export * from './types'; diff --git a/plugins/gcp-projects/src/api/types.ts b/plugins/gcp-projects/src/api/types.ts new file mode 100644 index 0000000000..8dccaec5e2 --- /dev/null +++ b/plugins/gcp-projects/src/api/types.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Project = { + name: string; + projectNumber?: string; + projectId: string; + lifecycleState?: string; + createTime?: string; +}; + +export type ProjectDetails = { + details: string; +}; + +export type Operation = { + name: string; + metadata: string; + done: boolean; + error: Status; + response: string; +}; + +export type Status = { + code: number; + message: string; + details: string[]; +}; diff --git a/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx b/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx new file mode 100644 index 0000000000..0149f8ac92 --- /dev/null +++ b/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx @@ -0,0 +1,131 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useState } from 'react'; +import { Grid, Button, TextField } from '@material-ui/core'; + +import { + InfoCard, + Content, + ContentHeader, + SimpleStepper, + SimpleStepperStep, + StructuredMetadataTable, + HeaderLabel, + Page, + Header, + pageTheme, + SupportButton, +} from '@backstage/core'; + +export const Project: FC<{}> = () => { + const [projectName, setProjectName] = useState(''); + const [projectId, setProjectId] = useState(''); + const [disabled, setDisabled] = useState(true); + + const metadata = { + ProjectName: projectName, + ProjectId: projectId, + }; + + return ( + + + + + + + setProjectName(e.target.value)} + value={projectName} + fullWidth + /> + + + setProjectId(e.target.value)} + value={projectId} + fullWidth + /> + + + setDisabled(false), + }} + > + + + + + + + + + + ); +}; + +const labels = ( + <> + + + +); + +export const NewProjectPage = () => { + return ( + +
+ {labels} +
+ + + + This plugin allows you to view and interact with your gcp projects. + + + + +
+ ); +}; diff --git a/plugins/gcp-projects/src/components/NewProjectPage/index.ts b/plugins/gcp-projects/src/components/NewProjectPage/index.ts new file mode 100644 index 0000000000..1d2f023887 --- /dev/null +++ b/plugins/gcp-projects/src/components/NewProjectPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { NewProjectPage } from './NewProjectPage'; diff --git a/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx b/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx new file mode 100644 index 0000000000..41d0b11019 --- /dev/null +++ b/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx @@ -0,0 +1,171 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Button, + ButtonGroup, + LinearProgress, + makeStyles, + Paper, + Table, + TableBody, + TableCell, + TableRow, + Theme, + Typography, +} from '@material-ui/core'; +import { + useApi, + googleAuthApiRef, + HeaderLabel, + Page, + Header, + pageTheme, + SupportButton, + Content, + ContentHeader, +} from '@backstage/core'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { GCPApiRef } from '../../api'; + +const useStyles = makeStyles(theme => ({ + root: { + maxWidth: 720, + margin: theme.spacing(2), + }, + title: { + padding: theme.spacing(1, 0, 2, 0), + }, + table: { + padding: theme.spacing(1), + }, +})); + +const DetailsPage = () => { + const api = useApi(GCPApiRef); + const googleApi = useApi(googleAuthApiRef); + const token = googleApi.getAccessToken( + 'https://www.googleapis.com/auth/cloud-platform.read-only', + ); + + const classes = useStyles(); + const status = useAsync( + () => + api.getProject( + decodeURIComponent(location.search.split('projectId=')[1]), + token, + ), + [location.search], + ); + + if (status.loading) { + return ; + } else if (status.error) { + return ( + + Failed to load build, {status.error.message} + + ); + } + + const details = status.value; + + return ( +
+
+ + + + Name + + {details?.name} + + + + Project Number + + {details?.projectNumber} + + + + Project ID + + {details?.projectId} + + + + State + + {details?.lifecycleState} + + + + Creation Time + + {details?.createTime} + + + + Links + + + + {details?.name && ( + + )} + {details?.name && ( + + )} + + + + +
+ + ); +}; + +const labels = ( + <> + + + +); + +export const ProjectDetailsPage = () => { + return ( + +
+ {labels} +
+ + + Support Button + + + +
+ ); +}; diff --git a/plugins/gcp-projects/src/components/ProjectDetailsPage/index.ts b/plugins/gcp-projects/src/components/ProjectDetailsPage/index.ts new file mode 100644 index 0000000000..e9b6eb095a --- /dev/null +++ b/plugins/gcp-projects/src/components/ProjectDetailsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ProjectDetailsPage } from './ProjectDetailsPage'; diff --git a/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx new file mode 100644 index 0000000000..7e09a307c9 --- /dev/null +++ b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx @@ -0,0 +1,164 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// NEEDS WORK + +import { + Link, + useApi, + googleAuthApiRef, + HeaderLabel, + Page, + Header, + pageTheme, + SupportButton, + Content, + ContentHeader, +} from '@backstage/core'; +import { + LinearProgress, + Paper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, + Button, +} from '@material-ui/core'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { GCPApiRef, Project } from '../../api'; + +const LongText = ({ text, max }: { text: string; max: number }) => { + if (text.length < max) { + return {text}; + } + return ( + + {text.slice(0, max)}... + + ); +}; + +const labels = ( + <> + + + +); + +const PageContents = () => { + const api = useApi(GCPApiRef); + const googleApi = useApi(googleAuthApiRef); + + const { loading, error, value } = useAsync(async () => { + const token = await googleApi.getAccessToken( + 'https://www.googleapis.com/auth/cloud-platform.read-only', + ); + + const projects = api.listProjects({ token }); + return projects; + }); + + if (loading) { + return ; + } + + if (error) { + return ( + + {error.message}{' '} + + ); + } + + return ( + +
+ + + Name + Project Number + Project ID + State + Creation Time + + + + {value?.map((project: Project) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ))} + +
+ + ); +}; + +export const ProjectListPage = () => { + return ( + +
+ {labels} +
+ + + + All your software catalog entities + + + +
+ ); +}; diff --git a/plugins/gcp-projects/src/components/ProjectListPage/index.ts b/plugins/gcp-projects/src/components/ProjectListPage/index.ts new file mode 100644 index 0000000000..c2b0479cef --- /dev/null +++ b/plugins/gcp-projects/src/components/ProjectListPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ProjectListPage } from './ProjectListPage'; diff --git a/plugins/gcp-projects/src/index.ts b/plugins/gcp-projects/src/index.ts new file mode 100644 index 0000000000..d67bc6a864 --- /dev/null +++ b/plugins/gcp-projects/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { plugin } from './plugin'; +export * from './api'; diff --git a/plugins/gcp-projects/src/plugin.test.ts b/plugins/gcp-projects/src/plugin.test.ts new file mode 100644 index 0000000000..86e909995f --- /dev/null +++ b/plugins/gcp-projects/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { plugin } from './plugin'; + +describe('gcp-projects', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/gcp-projects/src/plugin.ts b/plugins/gcp-projects/src/plugin.ts new file mode 100644 index 0000000000..41aacde597 --- /dev/null +++ b/plugins/gcp-projects/src/plugin.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createPlugin, + createRouteRef, + createApiFactory, +} from '@backstage/core'; +import { ProjectListPage } from './components/ProjectListPage'; +import { ProjectDetailsPage } from './components/ProjectDetailsPage'; +import { NewProjectPage } from './components/NewProjectPage'; +import { GCPApiRef, GCPClient } from './api'; + +export const rootRouteRef = createRouteRef({ + path: '/gcp-projects', + title: 'GCP Projects', +}); +export const ProjectRouteRef = createRouteRef({ + path: '/gcp-projects/project', + title: 'GCP Project Page', +}); +export const NewProjectRouteRef = createRouteRef({ + path: '/gcp-projects/new', + title: 'GCP Project Page', +}); + +export const plugin = createPlugin({ + id: 'gcp-projects', + apis: [createApiFactory(GCPApiRef, new GCPClient())], + register({ router }) { + router.addRoute(rootRouteRef, ProjectListPage); + router.addRoute(ProjectRouteRef, ProjectDetailsPage); + router.addRoute(NewProjectRouteRef, NewProjectPage); + }, +}); diff --git a/plugins/gcp-projects/src/setupTests.ts b/plugins/gcp-projects/src/setupTests.ts new file mode 100644 index 0000000000..8553642152 --- /dev/null +++ b/plugins/gcp-projects/src/setupTests.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; + +require('jest-fetch-mock').enableMocks(); diff --git a/plugins/github-actions/scripts/sample.yaml b/plugins/github-actions/examples/sample.yaml similarity index 74% rename from plugins/github-actions/scripts/sample.yaml rename to plugins/github-actions/examples/sample.yaml index d582bf9c35..7d9de50463 100644 --- a/plugins/github-actions/scripts/sample.yaml +++ b/plugins/github-actions/examples/sample.yaml @@ -5,6 +5,7 @@ metadata: description: backstage.io annotations: github.com/project-slug: 'spotify/backstage' + backstage.io/techdocs-ref: github:https://github.com/spotify/backstage.git spec: type: website lifecycle: production diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 8fcc4591d2..49862dea80 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -18,29 +18,30 @@ "diff": "backstage-cli plugin:diff", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean", - "mock-data": "./scripts/mock-data.sh" + "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.18", - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/core-api": "^0.1.1-alpha.18", - "@backstage/plugin-catalog": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/core-api": "^0.1.1-alpha.21", + "@backstage/plugin-catalog": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@octokit/rest": "^18.0.0", - "@octokit/types": "^5.0.1", + "@octokit/types": "^5.4.1", "moment": "^2.27.0", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-lazylog": "^4.5.3", + "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/github-actions/scripts/mock-data.sh b/plugins/github-actions/scripts/mock-data.sh deleted file mode 100755 index 2653a415dd..0000000000 --- a/plugins/github-actions/scripts/mock-data.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - - curl \ - --location \ - --request POST 'localhost:7000/catalog/locations' \ - --header 'Content-Type: application/json' \ - --data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/scripts/sample.yaml\"}" - echo diff --git a/plugins/github-actions/src/api/GithubActionsApi.ts b/plugins/github-actions/src/api/GithubActionsApi.ts index acdfb90cf3..bf79e74351 100644 --- a/plugins/github-actions/src/api/GithubActionsApi.ts +++ b/plugins/github-actions/src/api/GithubActionsApi.ts @@ -19,11 +19,12 @@ import { ActionsListWorkflowRunsForRepoResponseData, ActionsGetWorkflowResponseData, ActionsGetWorkflowRunResponseData, + EndpointInterface, } from '@octokit/types'; export const githubActionsApiRef = createApiRef({ id: 'plugin.githubactions.service', - description: 'Used by the Github Actions plugin to make requests', + description: 'Used by the GitHub Actions plugin to make requests', }); export type GithubActionsApi = { @@ -75,4 +76,15 @@ export type GithubActionsApi = { repo: string; runId: number; }) => Promise; + downloadJobLogsForWorkflowRun: ({ + token, + owner, + repo, + runId, + }: { + token: string; + owner: string; + repo: string; + runId: number; + }) => Promise; }; diff --git a/plugins/github-actions/src/api/GithubActionsClient.ts b/plugins/github-actions/src/api/GithubActionsClient.ts index 72f65075e6..94fb774537 100644 --- a/plugins/github-actions/src/api/GithubActionsClient.ts +++ b/plugins/github-actions/src/api/GithubActionsClient.ts @@ -20,6 +20,7 @@ import { ActionsListWorkflowRunsForRepoResponseData, ActionsGetWorkflowResponseData, ActionsGetWorkflowRunResponseData, + EndpointInterface, } from '@octokit/types'; export class GithubActionsClient implements GithubActionsApi { @@ -102,4 +103,24 @@ export class GithubActionsClient implements GithubActionsApi { }); return run.data; } + async downloadJobLogsForWorkflowRun({ + token, + owner, + repo, + runId, + }: { + token: string; + owner: string; + repo: string; + runId: number; + }): Promise { + const workflow = await new Octokit({ + auth: token, + }).actions.downloadJobLogsForWorkflowRun({ + owner, + repo, + job_id: runId, + }); + return workflow.data; + } } diff --git a/plugins/github-actions/src/api/types.ts b/plugins/github-actions/src/api/types.ts index cd74a20cda..ccc9198e58 100644 --- a/plugins/github-actions/src/api/types.ts +++ b/plugins/github-actions/src/api/types.ts @@ -29,6 +29,7 @@ export type Job = { conclusion: string; started_at: string; completed_at: string; + id: string; name: string; steps: Step[]; }; diff --git a/plugins/github-actions/src/components/Widget/Widget.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx similarity index 83% rename from plugins/github-actions/src/components/Widget/Widget.tsx rename to plugins/github-actions/src/components/Cards/Cards.tsx index 031364c8ce..28783efdea 100644 --- a/plugins/github-actions/src/components/Widget/Widget.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -15,7 +15,7 @@ */ import React, { useEffect } from 'react'; import { useWorkflowRuns } from '../useWorkflowRuns'; -import { WorkflowRun } from '../WorkflowRunsTable'; +import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable'; import { Entity } from '@backstage/catalog-model'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import { @@ -32,6 +32,7 @@ import { useApi, } from '@backstage/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; +import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; const useStyles = makeStyles({ externalLinkIcon: { @@ -74,7 +75,7 @@ const WidgetContent = ({ ); }; -export const Widget = ({ +export const LatestWorkflowRunCard = ({ entity, branch = 'master', }: { @@ -83,7 +84,7 @@ export const Widget = ({ }) => { const errorApi = useApi(errorApiRef); const [owner, repo] = ( - entity?.metadata.annotations?.['backstage.io/github-actions-id'] ?? '/' + entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/' ).split('/'); const [{ runs, loading, error }] = useWorkflowRuns({ owner, @@ -108,3 +109,15 @@ export const Widget = ({ ); }; + +export const LatestWorkflowsForBranchCard = ({ + entity, + branch = 'master', +}: { + entity: Entity; + branch: string; +}) => ( + + + +); diff --git a/plugins/github-actions/src/components/Cards/index.ts b/plugins/github-actions/src/components/Cards/index.ts new file mode 100644 index 0000000000..8c987ea1d5 --- /dev/null +++ b/plugins/github-actions/src/components/Cards/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { LatestWorkflowRunCard, LatestWorkflowsForBranchCard } from './Cards'; diff --git a/plugins/github-actions/src/components/Router.tsx b/plugins/github-actions/src/components/Router.tsx new file mode 100644 index 0000000000..7d340fb8b8 --- /dev/null +++ b/plugins/github-actions/src/components/Router.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { Routes, Route } from 'react-router'; +import { rootRouteRef, buildRouteRef } from '../plugin'; +import { WorkflowRunDetails } from './WorkflowRunDetails'; +import { WorkflowRunsTable } from './WorkflowRunsTable'; +import { GITHUB_ACTIONS_ANNOTATION } from './useProjectName'; +import { WarningPanel } from '@backstage/core'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]) && + entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] !== ''; + +export const Router = ({ entity }: { entity: Entity }) => + // TODO(shmidt-i): move warning to a separate standardized component + !isPluginApplicableToEntity(entity) ? ( + +
{GITHUB_ACTIONS_ANNOTATION}
annotation is missing on the + entity. +
+ ) : ( + + } + /> + } + /> + ) + + ); diff --git a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx index 8c445ca9f6..fd2cc69376 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx +++ b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx @@ -13,35 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { useEntityCompoundName } from '@backstage/plugin-catalog'; -import { useWorkflowRunsDetails } from './useWorkflowRunsDetails'; -import { useWorkflowRunJobs } from './useWorkflowRunJobs'; -import { useProjectName } from '../useProjectName'; +import { Entity } from '@backstage/catalog-model'; +import { Link } from '@backstage/core'; import { - makeStyles, + Accordion, + AccordionDetails, + AccordionSummary, Box, - TableRow, - TableCell, - ListItemText, - ExpansionPanel, - ExpansionPanelSummary, - Typography, - ExpansionPanelDetails, - TableContainer, - Table, - Paper, - TableBody, - LinearProgress, + Breadcrumbs, CircularProgress, + LinearProgress, + Link as MaterialLink, + ListItemText, + makeStyles, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, Theme, - Link, + Typography, } from '@material-ui/core'; -import { Jobs, Job, Step } from '../../api'; -import moment from 'moment'; -import { WorkflowRunStatus } from '../WorkflowRunStatus'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ExternalLinkIcon from '@material-ui/icons/Launch'; +import moment from 'moment'; +import React from 'react'; +import { Job, Jobs, Step } from '../../api'; +import { useProjectName } from '../useProjectName'; +import { WorkflowRunStatus } from '../WorkflowRunStatus'; +import { useWorkflowRunJobs } from './useWorkflowRunJobs'; +import { useWorkflowRunsDetails } from './useWorkflowRunsDetails'; +import { WorkflowRunLogs } from '../WorkflowRunLogs'; const useStyles = makeStyles(theme => ({ root: { @@ -54,7 +57,7 @@ const useStyles = makeStyles(theme => ({ table: { padding: theme.spacing(1), }, - expansionPanelDetails: { + accordionDetails: { padding: 0, }, button: { @@ -68,7 +71,7 @@ const useStyles = makeStyles(theme => ({ }, })); -const JobsList = ({ jobs }: { jobs?: Jobs }) => { +const JobsList = ({ jobs, entity }: { jobs?: Jobs; entity: Entity }) => { const classes = useStyles(); return ( @@ -80,6 +83,7 @@ const JobsList = ({ jobs }: { jobs?: Jobs }) => { className={ job.status !== 'success' ? classes.failed : classes.success } + entity={entity} /> ))} @@ -108,14 +112,19 @@ const StepView = ({ step }: { step: Step }) => { ); }; -const JobListItem = ({ job, className }: { job: Job; className: string }) => { +const JobListItem = ({ + job, + className, + entity, +}: { + job: Job; + className: string; + entity: Entity; +}) => { const classes = useStyles(); return ( - - + } aria-controls={`panel-${name}-content`} id={`panel-${name}-header`} @@ -126,8 +135,8 @@ const JobListItem = ({ job, className }: { job: Job; className: string }) => { {job.name} ({getElapsedTime(job.started_at, job.completed_at)}) - - + + {job.steps.map((step: Step) => ( @@ -135,23 +144,18 @@ const JobListItem = ({ job, className }: { job: Job; className: string }) => { ))}
-
-
+ + {job.status === 'queued' || job.status === 'in_progress' ? ( + + ) : ( + + )} + ); }; -export const WorkflowRunDetails = () => { - let entityCompoundName = useEntityCompoundName(); - if (!entityCompoundName.name) { - // TODO(shmidt-i): remove when is fully integrated - // into the entity view - entityCompoundName = { - kind: 'Component', - name: 'backstage', - namespace: 'default', - }; - } - const projectName = useProjectName(entityCompoundName); +export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => { + const projectName = useProjectName(entity); const [owner, repo] = projectName.value ? projectName.value.split('/') : []; const details = useWorkflowRunsDetails(repo, owner); @@ -170,6 +174,10 @@ export const WorkflowRunDetails = () => { } return (
+ + Workflow runs + Workflow run details + @@ -211,10 +219,10 @@ export const WorkflowRunDetails = () => { {details.value?.html_url && ( - + Workflow runs on GitHub{' '} - + )} @@ -224,7 +232,7 @@ export const WorkflowRunDetails = () => { {jobs.loading ? ( ) : ( - + )} diff --git a/plugins/github-actions/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.tsx b/plugins/github-actions/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.tsx deleted file mode 100644 index ec9a3f484d..0000000000 --- a/plugins/github-actions/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Typography, Grid, Breadcrumbs } from '@material-ui/core'; - -import React from 'react'; -import { - Link, - Page, - Header, - HeaderLabel, - Content, - ContentHeader, - SupportButton, - pageTheme, -} from '@backstage/core'; - -import { WorkflowRunDetails } from '../WorkflowRunDetails'; - -/** - * A component for Jobs visualization. Jobs are a property of a Workflow Run. - */ -export const WorkflowRunDetailsPage = () => { - return ( - -
- - -
- - - - This plugin allows you to view and interact with your builds within - the GitHub Actions environment. - - - - Workflow runs - Workflow run details - - - - - - - -
- ); -}; diff --git a/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx new file mode 100644 index 0000000000..390f35d516 --- /dev/null +++ b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx @@ -0,0 +1,169 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Accordion, + AccordionSummary, + CircularProgress, + Fade, + LinearProgress, + makeStyles, + Modal, + Theme, + Tooltip, + Typography, + Zoom, +} from '@material-ui/core'; + +import React, { Suspense } from 'react'; +import { useDownloadWorkflowRunLogs } from './useDownloadWorkflowRunLogs'; +import LinePart from 'react-lazylog/build/LinePart'; +import { useProjectName } from '../useProjectName'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import DescriptionIcon from '@material-ui/icons/Description'; +import { Entity } from '@backstage/catalog-model'; + +const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); + +const useStyles = makeStyles(() => ({ + button: { + order: -1, + marginRight: 0, + marginLeft: '-20px', + }, + modal: { + display: 'flex', + alignItems: 'center', + width: '85%', + height: '85%', + justifyContent: 'center', + margin: 'auto', + }, + normalLog: { + height: '75vh', + width: '100%', + }, + modalLog: { + height: '100%', + width: '100%', + }, +})); + +const DisplayLog = ({ + jobLogs, + className, +}: { + jobLogs: any; + className: string; +}) => { + return ( + }> +
+ { + if ( + line.toLocaleLowerCase().includes('error') || + line.toLocaleLowerCase().includes('failed') || + line.toLocaleLowerCase().includes('failure') + ) { + return ( + + ); + } + return line; + }} + /> +
+
+ ); +}; + +/** + * A component for Run Logs visualization. + */ +export const WorkflowRunLogs = ({ + entity, + runId, + inProgress, +}: { + entity: Entity; + runId: string; + inProgress: boolean; +}) => { + const classes = useStyles(); + const projectName = useProjectName(entity); + + const [owner, repo] = projectName.value ? projectName.value.split('/') : []; + const jobLogs = useDownloadWorkflowRunLogs(repo, owner, runId); + const [open, setOpen] = React.useState(false); + + const handleOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + return ( + + } + aria-controls={`panel-${name}-content`} + id={`panel-${name}-header`} + IconButtonProps={{ + className: classes.button, + }} + > + + {jobLogs.loading ? : 'Job Log'} + + + { + event.stopPropagation(); + handleOpen(); + }} + style={{ marginLeft: 'auto' }} + /> + + event.stopPropagation()} + open={open} + onClose={handleClose} + > + + + + + + {jobLogs.value && ( + + )} + + ); +}; diff --git a/plugins/github-actions/src/components/WorkflowRunLogs/index.ts b/plugins/github-actions/src/components/WorkflowRunLogs/index.ts new file mode 100644 index 0000000000..0fcffd4dec --- /dev/null +++ b/plugins/github-actions/src/components/WorkflowRunLogs/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { WorkflowRunLogs } from './WorkflowRunLogs'; diff --git a/plugins/github-actions/src/components/WorkflowRunLogs/useDownloadWorkflowRunLogs.ts b/plugins/github-actions/src/components/WorkflowRunLogs/useDownloadWorkflowRunLogs.ts new file mode 100644 index 0000000000..734f617b1f --- /dev/null +++ b/plugins/github-actions/src/components/WorkflowRunLogs/useDownloadWorkflowRunLogs.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi, githubAuthApiRef } from '@backstage/core'; +import { useAsync } from 'react-use'; +import { githubActionsApiRef } from '../../api'; + +export const useDownloadWorkflowRunLogs = ( + repo: string, + owner: string, + id: string, +) => { + const api = useApi(githubActionsApiRef); + const auth = useApi(githubAuthApiRef); + const details = useAsync(async () => { + const token = await auth.getAccessToken(['repo']); + return repo && owner + ? api.downloadJobLogsForWorkflowRun({ + token, + owner, + repo, + runId: parseInt(id, 10), + }) + : Promise.reject('No repo/owner provided'); + }, [repo, owner, id]); + return details; +}; diff --git a/plugins/github-actions/src/components/WorkflowRunsPage/WorkflowRunsPage.tsx b/plugins/github-actions/src/components/WorkflowRunsPage/WorkflowRunsPage.tsx deleted file mode 100644 index b75a70f326..0000000000 --- a/plugins/github-actions/src/components/WorkflowRunsPage/WorkflowRunsPage.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - Header, - HeaderLabel, - pageTheme, - Page, - Content, - ContentHeader, - SupportButton, -} from '@backstage/core'; -import { Grid } from '@material-ui/core'; -import React from 'react'; - -import { WorkflowRunsTable } from '../WorkflowRunsTable'; - -export const WorkflowRunsPage = () => { - return ( - -
- - -
- - - - This plugin allows you to view and interact with your builds within - the GitHub Actions environment. - - - - - - - - -
- ); -}; diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index a2a6ead9b5..cc834f09ee 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -23,8 +23,8 @@ import { useWorkflowRuns } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import SyncIcon from '@material-ui/icons/Sync'; import { buildRouteRef } from '../../plugin'; -import { useEntityCompoundName } from '@backstage/plugin-catalog'; import { useProjectName } from '../useProjectName'; +import { Entity } from '@backstage/catalog-model'; export type WorkflowRun = { id: string; @@ -134,6 +134,7 @@ export const WorkflowRunsTableView: FC = ({ data={runs ?? []} onChangePage={onChangePage} onChangeRowsPerPage={onChangePageSize} + style={{ width: '100%' }} title={ @@ -146,25 +147,21 @@ export const WorkflowRunsTableView: FC = ({ ); }; -export const WorkflowRunsTable = () => { - let entityCompoundName = useEntityCompoundName(); - - if (!entityCompoundName.name) { - // TODO(shmidt-i): remove when is fully integrated - // into the entity view - entityCompoundName = { - kind: 'Component', - name: 'backstage', - namespace: 'default', - }; - } - - const { value: projectName, loading } = useProjectName(entityCompoundName); +export const WorkflowRunsTable = ({ + entity, + branch, +}: { + entity: Entity; + branch?: string; +}) => { + const { value: projectName, loading } = useProjectName(entity); const [owner, repo] = (projectName ?? '/').split('/'); const [tableProps, { retry, setPage, setPageSize }] = useWorkflowRuns({ owner, repo, + branch, }); + return ( { - const catalogApi = useApi(catalogApiRef); +export const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug'; +export const useProjectName = (entity: Entity) => { const { value, loading, error } = useAsync(async () => { - const entity = await catalogApi.getEntityByName(name); - return entity?.metadata.annotations?.['github.com/project-slug'] ?? ''; + return entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? ''; }); return { value, loading, error }; }; diff --git a/plugins/github-actions/src/index.ts b/plugins/github-actions/src/index.ts index 4a69c363cd..24fe6fc90d 100644 --- a/plugins/github-actions/src/index.ts +++ b/plugins/github-actions/src/index.ts @@ -16,4 +16,6 @@ export { plugin } from './plugin'; export * from './api'; -export { Widget } from './components/Widget'; +export { Router, isPluginApplicableToEntity } from './components/Router'; +export * from './components/Cards'; +export { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName'; diff --git a/plugins/github-actions/src/plugin.ts b/plugins/github-actions/src/plugin.ts index dbc366bd71..9e3c965d46 100644 --- a/plugins/github-actions/src/plugin.ts +++ b/plugins/github-actions/src/plugin.ts @@ -14,29 +14,25 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import { WorkflowRunDetailsPage } from './components/WorkflowRunDetailsPage'; -import { WorkflowRunsPage } from './components/WorkflowRunsPage'; +import { + createPlugin, + createRouteRef, + createApiFactory, +} from '@backstage/core'; +import { githubActionsApiRef, GithubActionsClient } from './api'; // TODO(freben): This is just a demo route for now export const rootRouteRef = createRouteRef({ - path: '/github-actions', + path: '', title: 'GitHub Actions', }); -export const projectRouteRef = createRouteRef({ - path: '/github-actions/:kind/:optionalNamespaceAndName/', - title: 'GitHub Actions for project', -}); + export const buildRouteRef = createRouteRef({ - path: '/github-actions/workflow-run/:id', + path: ':id', title: 'GitHub Actions Workflow Run', }); export const plugin = createPlugin({ id: 'github-actions', - register({ router }) { - router.addRoute(rootRouteRef, WorkflowRunsPage); - router.addRoute(projectRouteRef, WorkflowRunsPage); - router.addRoute(buildRouteRef, WorkflowRunDetailsPage); - }, + apis: [createApiFactory(githubActionsApiRef, new GithubActionsClient())], }); diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 5815386014..62dacb5e91 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-gitops-profiles", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", @@ -32,8 +32,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx index 50268e6a50..06074ff84d 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx @@ -26,6 +26,7 @@ import { githubAuthApiRef, GithubAuth, OAuthRequestManager, + UrlPatternDiscovery, } from '@backstage/core'; import { gitOpsApiRef, GitOpsRestApi } from '../../api'; @@ -37,8 +38,9 @@ describe('ProfileCatalog', () => { [ githubAuthApiRef, GithubAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', + discoveryApi: UrlPatternDiscovery.compile( + 'http://example.com/{{pluginId}}', + ), oauthRequestApi, }), ], diff --git a/plugins/gitops-profiles/src/plugin.ts b/plugins/gitops-profiles/src/plugin.ts index af180aff20..45820644f7 100644 --- a/plugins/gitops-profiles/src/plugin.ts +++ b/plugins/gitops-profiles/src/plugin.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; +import { createPlugin, createApiFactory } from '@backstage/core'; import ProfileCatalog from './components/ProfileCatalog'; import ClusterPage from './components/ClusterPage'; import ClusterList from './components/ClusterList'; @@ -23,9 +23,13 @@ import { gitOpsClusterDetailsRoute, gitOpsClusterCreateRoute, } from './routes'; +import { gitOpsApiRef, GitOpsRestApi } from './api'; export const plugin = createPlugin({ id: 'gitops-profiles', + apis: [ + createApiFactory(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')), + ], register({ router }) { router.addRoute(gitOpsClusterListRoute, ClusterList); router.addRoute(gitOpsClusterDetailsRoute, ClusterPage); diff --git a/plugins/graphiql/dev/index.tsx b/plugins/graphiql/dev/index.tsx index efcf19a89d..b93995a5dc 100644 --- a/plugins/graphiql/dev/index.tsx +++ b/plugins/graphiql/dev/index.tsx @@ -20,8 +20,8 @@ import { plugin, GraphQLEndpoints, graphQlBrowseApiRef } from '../src'; createDevApp() .registerPlugin(plugin) - .registerApiFactory({ - implements: graphQlBrowseApiRef, + .registerApi({ + api: graphQlBrowseApiRef, deps: { errorApi: errorApiRef, githubAuthApi: githubAuthApiRef, diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index a034c14a11..b4d0b685da 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-graphiql", "description": "Backstage plugin for browsing GraphQL APIs", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "private": false, "publishConfig": { "access": "public", @@ -31,25 +31,25 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "graphiql": "^1.0.0-alpha.10", - "graphql": "15.1.0", + "graphql": "15.3.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", - "@backstage/test-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", - "@types/codemirror": "^0.0.96", + "@types/codemirror": "^0.0.97", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3", diff --git a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx index b41fc04b9e..ce56017984 100644 --- a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx +++ b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx @@ -24,7 +24,7 @@ import { BackstageTheme } from '@backstage/theme'; const GraphiQL = React.lazy(() => import('graphiql')); -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(theme => ({ root: { height: '100%', display: 'flex', diff --git a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx index a16fdf3ee6..7eb91b59d8 100644 --- a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx +++ b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Content, Header, @@ -30,7 +30,7 @@ import { graphQlBrowseApiRef } from '../../lib/api'; import { GraphiQLBrowser } from '../GraphiQLBrowser'; import { Typography } from '@material-ui/core'; -export const GraphiQLPage: FC<{}> = () => { +export const GraphiQLPage = () => { const graphQlBrowseApi = useApi(graphQlBrowseApiRef); const endpoints = useAsync(() => graphQlBrowseApi.getEndpoints()); diff --git a/plugins/graphiql/src/index.ts b/plugins/graphiql/src/index.ts index e7614aba81..50698cdb4c 100644 --- a/plugins/graphiql/src/index.ts +++ b/plugins/graphiql/src/index.ts @@ -15,5 +15,6 @@ */ export { plugin } from './plugin'; +export { GraphiQLPage as Router } from './components'; export * from './lib/api'; export * from './route-refs'; diff --git a/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts b/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts index ee0b4bfd4e..23a9e9e697 100644 --- a/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts +++ b/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts @@ -33,7 +33,7 @@ export type GithubEndpointConfig = { id: string; title: string; /** - * Github GraphQL API url, defaults to https://api.github.com/graphql + * GitHub GraphQL API url, defaults to https://api.github.com/graphql */ url?: string; /** diff --git a/plugins/graphiql/src/plugin.ts b/plugins/graphiql/src/plugin.ts index f118faf871..28d27802d0 100644 --- a/plugins/graphiql/src/plugin.ts +++ b/plugins/graphiql/src/plugin.ts @@ -14,13 +14,22 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; -import { GraphiQLPage } from './components'; -import { graphiQLRouteRef } from './route-refs'; +import { createPlugin, createApiFactory } from '@backstage/core'; +import { graphQlBrowseApiRef, GraphQLEndpoints } from './lib/api'; export const plugin = createPlugin({ id: 'graphiql', - register({ router }) { - router.addRoute(graphiQLRouteRef, GraphiQLPage); - }, + apis: [ + // GitLab is used as an example endpoint, but most plug + createApiFactory( + graphQlBrowseApiRef, + GraphQLEndpoints.from([ + GraphQLEndpoints.create({ + id: 'gitlab', + title: 'GitLab', + url: 'https://gitlab.com/api/graphql', + }), + ]), + ), + ], }); diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index 35aac33451..9ad0d7c156 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-graphql-backend", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,9 +19,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.18", - "@backstage/config": "^0.1.1-alpha.18", - "@backstage/plugin-catalog-graphql": "^0.1.1-alpha.18", + "@backstage/backend-common": "^0.1.1-alpha.21", + "@backstage/config": "^0.1.1-alpha.21", + "@backstage/plugin-catalog-graphql": "^0.1.1-alpha.21", "@graphql-modules/core": "^0.7.17", "@types/express": "^4.17.6", "apollo-server": "^2.16.1", @@ -36,10 +36,10 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", - "msw": "^0.19.5", + "msw": "^0.20.5", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/graphql/src/service/router.ts b/plugins/graphql/src/service/router.ts index a06f696533..34bfaae2d5 100644 --- a/plugins/graphql/src/service/router.ts +++ b/plugins/graphql/src/service/router.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { errorHandler } from '@backstage/backend-common'; +import { errorHandler, resolvePackagePath } from '@backstage/backend-common'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; @@ -26,9 +26,9 @@ import { createModule as createCatalogModule } from '@backstage/plugin-catalog-g import { Config } from '@backstage/config'; import helmet from 'helmet'; -const schemaPath = path.resolve( - require.resolve('@backstage/plugin-graphql-backend/package.json'), - '../schema.gql', +const schemaPath = resolvePackagePath( + '@backstage/plugin-graphql-backend', + 'schema.gql', ); export interface RouterOptions { diff --git a/plugins/identity-backend/README.md b/plugins/identity-backend/README.md index 7d97bb5f6b..c95fed0698 100644 --- a/plugins/identity-backend/README.md +++ b/plugins/identity-backend/README.md @@ -8,4 +8,4 @@ It responds to identity requests from the frontend. ## Links -- (The Backstage homepage)[https://backstage.io] +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/identity-backend/package.json b/plugins/identity-backend/package.json index c5c3bffc9b..18db7c9220 100644 --- a/plugins/identity-backend/package.json +++ b/plugins/identity-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-identity-backend", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,7 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.18", + "@backstage/backend-common": "^0.1.1-alpha.21", "@types/express": "^4.17.6", "compression": "^1.7.4", "cors": "^2.8.5", @@ -33,7 +33,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", "jest-fetch-mock": "^3.0.3" }, "files": [ diff --git a/plugins/identity-backend/src/service/router.ts b/plugins/identity-backend/src/service/router.ts index 9c6515ac04..9d16e8f332 100644 --- a/plugins/identity-backend/src/service/router.ts +++ b/plugins/identity-backend/src/service/router.ts @@ -27,13 +27,15 @@ export interface RouterOptions { const makeRouter = (adapter: IdentityApi): express.Router => { const router = Router(); + router.use(express.json()); + router.get('/users/:user/groups', async (req, res) => { const user = req.params.user; const type = req.query.type?.toString() ?? ''; - const response = await adapter.getUserGroups({ user, type }); res.send(response); }); + return router; }; diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index b8a844f5d8..8f0d43cba7 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -4,7 +4,7 @@ Website: [https://jenkins.io/](https://jenkins.io/) Last master build Folder results -Build detials +Build details ## Setup @@ -14,17 +14,7 @@ Website: [https://jenkins.io/](https://jenkins.io/) yarn add @backstage/plugin-jenkins ``` -2. Add plugin API to your Backstage instance: - -```js -// packages/app/src/api.ts -import { JenkinsApi, jenkinsApiRef } from '@backstage/plugin-jenkins'; - -const builder = ApiRegistry.builder(); -builder.add(jenkinsApiRef, new JenkinsApi(`${backendUrl}/proxy/jenkins/api`)); -``` - -2. Add plugin itself: +2. Add plugin: ```js // packages/app/src/plugins.ts @@ -63,7 +53,7 @@ metadata: name: 'your-component' description: 'a description' annotations: - backstage.io/jenkins-github-folder: 'folder-name/job-name' + jenkins.io/github-folder: 'folder-name/job-name' spec: type: service lifecycle: experimental diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index dce577801e..c62df08db2 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.18", - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/plugin-catalog": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "jenkins": "^0.28.0", @@ -35,8 +36,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/jenkins/src/api/index.ts b/plugins/jenkins/src/api/index.ts index 545a1da9cc..2b95fe97d9 100644 --- a/plugins/jenkins/src/api/index.ts +++ b/plugins/jenkins/src/api/index.ts @@ -15,7 +15,7 @@ */ import { createApiRef } from '@backstage/core'; -import { CITableBuildInfo } from '../pages/BuildsPage/lib/CITable'; +import { CITableBuildInfo } from '../components/BuildsPage/lib/CITable'; const jenkins = require('jenkins'); @@ -65,6 +65,21 @@ export class JenkinsApi { }) .pop(); + const author = jobDetails.actions + .filter( + (action: any) => + action._class === + 'jenkins.scm.api.metadata.ContributorMetadataAction', + ) + .map((action: any) => { + return action.contributorDisplayName; + }) + .pop(); + + if (author) { + scmInfo.author = author; + } + return scmInfo; } @@ -154,12 +169,15 @@ export class JenkinsApi { if (jobScmInfo) { source.url = jobScmInfo?.url; source.displayName = jobScmInfo?.displayName; + source.author = jobScmInfo?.author; } const path = new URL(jenkinsResult.url).pathname; return { id: path, + buildNumber: jenkinsResult.number, + buildUrl: jenkinsResult.url, buildName: jenkinsResult.fullDisplayName, status: jenkinsResult.building ? 'running' : jenkinsResult.result, onRestartClick: () => { diff --git a/plugins/jenkins/src/assets/build-details.png b/plugins/jenkins/src/assets/build-details.png index 396af428af..ee463fea43 100644 Binary files a/plugins/jenkins/src/assets/build-details.png and b/plugins/jenkins/src/assets/build-details.png differ diff --git a/plugins/jenkins/src/assets/folder-results.png b/plugins/jenkins/src/assets/folder-results.png index 2e14f3551f..c01e4cf437 100644 Binary files a/plugins/jenkins/src/assets/folder-results.png and b/plugins/jenkins/src/assets/folder-results.png differ diff --git a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx new file mode 100644 index 0000000000..637cd40d6d --- /dev/null +++ b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -0,0 +1,134 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { Content, Link } from '@backstage/core'; +import { + Typography, + Breadcrumbs, + Paper, + TableContainer, + Table, + TableRow, + TableCell, + TableBody, + Link as MaterialLink, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { useBuildWithSteps } from '../useBuildWithSteps'; +import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity'; +import { JenkinsRunStatus } from '../BuildsPage/lib/Status'; +import ExternalLinkIcon from '@material-ui/icons/Launch'; + +const useStyles = makeStyles(theme => ({ + root: { + maxWidth: 720, + margin: theme.spacing(2), + }, + table: { + padding: theme.spacing(1), + }, + externalLinkIcon: { + fontSize: 'inherit', + verticalAlign: 'bottom', + }, +})); + +const Page = () => ( + + + +); + +const BuildWithStepsView = () => { + const { owner, repo } = useProjectSlugFromEntity(); + const { branch, buildNumber } = useParams(); + const classes = useStyles(); + const buildPath = `${owner}/${repo}/${branch}/${buildNumber}`; + const [{ value }] = useBuildWithSteps(buildPath); + + return ( +
+ + Jobs + Run + + +
+ + + + Branch + + {value?.source?.branchName} + + + + Message + + {value?.source?.displayName} + + + + Commit ID + + {value?.source?.commit?.hash} + + + + Status + + + + + + + + Author + + {value?.source?.author} + + + + Jenkins + + + + View on Jenkins{' '} + + + + + + + GitHub + + + + View on GitHub{' '} + + + + + +
+
+
+ ); +}; + +export default Page; +export { BuildWithStepsView as BuildWithSteps }; diff --git a/plugins/circleci/src/pages/BuildWithStepsPage/index.ts b/plugins/jenkins/src/components/BuildWithStepsPage/index.ts similarity index 100% rename from plugins/circleci/src/pages/BuildWithStepsPage/index.ts rename to plugins/jenkins/src/components/BuildWithStepsPage/index.ts diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx similarity index 76% rename from plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx rename to plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx index 1143121a98..0dcaeb8948 100644 --- a/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx +++ b/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx @@ -13,18 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect, FC } from 'react'; + import { - ExpansionPanel, - ExpansionPanelSummary, + Accordion, + AccordionDetails, + AccordionSummary, Typography, - ExpansionPanelDetails, } from '@material-ui/core'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { makeStyles } from '@material-ui/core/styles'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import React, { FC, useEffect } from 'react'; const useStyles = makeStyles({ - expansionPanelDetails: { + accordionDetails: { padding: 0, }, button: { @@ -45,11 +46,8 @@ export const ActionOutput: FC<{ useEffect(() => {}, [url]); return ( - - + } aria-controls={`panel-${name}-content`} id={`panel-${name}-header`} @@ -58,10 +56,10 @@ export const ActionOutput: FC<{ }} > {name} - - + + Nothing here... - - + + ); }; diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts b/plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/index.ts similarity index 100% rename from plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts rename to plugins/jenkins/src/components/BuildWithStepsPage/lib/ActionOutput/index.ts diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx similarity index 81% rename from plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx rename to plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 7f17107374..282cbc59b5 100644 --- a/plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -14,21 +14,26 @@ * limitations under the License. */ import React, { FC } from 'react'; -import { Link, Typography, Box, IconButton } from '@material-ui/core'; +import { Box, IconButton, Link, Typography } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; -import { Link as RouterLink } from 'react-router-dom'; +import { generatePath, Link as RouterLink } from 'react-router-dom'; import { Table, TableColumn } from '@backstage/core'; import { JenkinsRunStatus } from '../Status'; +import { useBuilds } from '../../../useBuilds'; +import { useProjectSlugFromEntity } from '../../../useProjectSlugFromEntity'; +import { buildRouteRef } from '../../../../plugin'; export type CITableBuildInfo = { id: string; buildName: string; - buildUrl?: string; + buildNumber: number; + buildUrl: string; source: { branchName: string; url: string; displayName: string; + author?: string; commit: { hash: string; }; @@ -105,7 +110,13 @@ const generatedColumns: TableColumn[] = [ field: 'buildName', highlight: true, render: (row: Partial) => ( - + {row.buildName} ), @@ -177,7 +188,8 @@ type Props = { pageSize: number; onChangePageSize: (pageSize: number) => void; }; -export const CITable: FC = ({ + +export const CITableView: FC = ({ projectName, loading, pageSize, @@ -191,7 +203,7 @@ export const CITable: FC = ({ return ( = ({ onClick: () => retry(), }, ]} - data={builds} + data={builds ?? []} onChangePage={onChangePage} onChangeRowsPerPage={onChangePageSize} title={ @@ -216,3 +228,18 @@ export const CITable: FC = ({ /> ); }; + +export const CITable = () => { + const { owner, repo } = useProjectSlugFromEntity(); + + const [tableProps, { setPage, retry, setPageSize }] = useBuilds(owner, repo); + + return ( + + ); +}; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/CITable/index.ts b/plugins/jenkins/src/components/BuildsPage/lib/CITable/index.ts similarity index 100% rename from plugins/jenkins/src/pages/BuildsPage/lib/CITable/index.ts rename to plugins/jenkins/src/components/BuildsPage/lib/CITable/index.ts diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Status/JenkinsRunStatus.tsx b/plugins/jenkins/src/components/BuildsPage/lib/Status/JenkinsRunStatus.tsx similarity index 100% rename from plugins/jenkins/src/pages/BuildsPage/lib/Status/JenkinsRunStatus.tsx rename to plugins/jenkins/src/components/BuildsPage/lib/Status/JenkinsRunStatus.tsx diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Status/index.ts b/plugins/jenkins/src/components/BuildsPage/lib/Status/index.ts similarity index 100% rename from plugins/jenkins/src/pages/BuildsPage/lib/Status/index.ts rename to plugins/jenkins/src/components/BuildsPage/lib/Status/index.ts diff --git a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx similarity index 78% rename from plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx rename to plugins/jenkins/src/components/Cards/Cards.tsx index 9bd16bf9e6..5034effdcf 100644 --- a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ import React from 'react'; -import { Entity } from '@backstage/catalog-model'; import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core'; import { InfoCard, StructuredMetadataTable } from '@backstage/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; -import { useBuilds } from '../../state'; -import { JenkinsRunStatus } from '../../pages/BuildsPage/lib/Status'; +import { useBuilds } from '../useBuilds'; +import { JenkinsRunStatus } from '../BuildsPage/lib/Status'; +import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity'; const useStyles = makeStyles({ externalLinkIcon: { @@ -38,6 +38,7 @@ const WidgetContent = ({ }) => { const classes = useStyles(); if (loading || !lastRun) return ; + return ( { - const [owner, repo] = ( - entity?.metadata.annotations?.['backstage.io/jenkins-github-folder'] ?? '/' - ).split('/'); - const [{ loading, value }] = useBuilds(owner, repo, branch); - - const lastRun = value ?? {}; - +export const LatestRunCard = ({ branch = 'master' }: { branch: string }) => { + const { owner, repo } = useProjectSlugFromEntity(); + const [{ builds, loading }] = useBuilds(owner, repo, branch); + const lastRun = builds ?? {}; return ( diff --git a/plugins/jenkins/src/components/Cards/index.ts b/plugins/jenkins/src/components/Cards/index.ts new file mode 100644 index 0000000000..dace09e1a9 --- /dev/null +++ b/plugins/jenkins/src/components/Cards/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { LatestRunCard } from './Cards'; diff --git a/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx b/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx deleted file mode 100644 index 501649e49a..0000000000 --- a/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { ContentHeader, SupportButton } from '@backstage/core'; -import { Box, Typography } from '@material-ui/core'; - -export type Props = { title?: string }; -export const PluginHeader = ({ title = 'Jenkins' }) => { - return ( - ( - - {title} - - )} - > - - This plugin allows you to view and interact with your builds in Jenkins. - - - ); -}; diff --git a/plugins/jenkins/src/components/Router.tsx b/plugins/jenkins/src/components/Router.tsx new file mode 100644 index 0000000000..56df5456bc --- /dev/null +++ b/plugins/jenkins/src/components/Router.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Route, Routes } from 'react-router'; +import { buildRouteRef, rootRouteRef } from '../plugin'; +import { DetailedViewPage } from './BuildWithStepsPage/'; +import { JENKINS_ANNOTATION } from '../constants'; +import { Entity } from '@backstage/catalog-model'; +import { WarningPanel } from '@backstage/core'; +import { CITable } from './BuildsPage/lib/CITable'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[JENKINS_ANNOTATION]) && + entity.metadata.annotations?.[JENKINS_ANNOTATION] !== ''; + +export const Router = ({ entity }: { entity: Entity }) => { + return !isPluginApplicableToEntity(entity) ? ( + +
entity.metadata.annotations['{JENKINS_ANNOTATION}']
+ key is missing on the entity. +
+ ) : ( + + } /> + } /> + + ); +}; diff --git a/plugins/jenkins/src/state/useAsyncPolling.ts b/plugins/jenkins/src/components/useAsyncPolling.ts similarity index 100% rename from plugins/jenkins/src/state/useAsyncPolling.ts rename to plugins/jenkins/src/components/useAsyncPolling.ts diff --git a/plugins/jenkins/src/state/useBuildWithSteps.ts b/plugins/jenkins/src/components/useBuildWithSteps.ts similarity index 97% rename from plugins/jenkins/src/state/useBuildWithSteps.ts rename to plugins/jenkins/src/components/useBuildWithSteps.ts index 617b2b1f4f..86d8162612 100644 --- a/plugins/jenkins/src/state/useBuildWithSteps.ts +++ b/plugins/jenkins/src/components/useBuildWithSteps.ts @@ -16,7 +16,7 @@ import { errorApiRef, useApi } from '@backstage/core'; import { useCallback } from 'react'; import { useAsyncRetry } from 'react-use'; -import { jenkinsApiRef } from '../api/index'; +import { jenkinsApiRef } from '../api'; import { useAsyncPolling } from './useAsyncPolling'; const INTERVAL_AMOUNT = 1500; diff --git a/plugins/jenkins/src/state/useBuilds.ts b/plugins/jenkins/src/components/useBuilds.ts similarity index 92% rename from plugins/jenkins/src/state/useBuilds.ts rename to plugins/jenkins/src/components/useBuilds.ts index deb3125637..62e3719dbd 100644 --- a/plugins/jenkins/src/state/useBuilds.ts +++ b/plugins/jenkins/src/components/useBuilds.ts @@ -56,8 +56,9 @@ export function useBuilds(owner: string, repo: string, branch?: string) { }); }, [repo, getBuilds]); - const { loading, value, retry } = useAsyncRetry( - () => getBuilds().then(builds => builds ?? [], restartBuild), + const { loading, value: builds, retry } = useAsyncRetry( + () => + getBuilds().then(retrievedBuilds => retrievedBuilds ?? [], restartBuild), [page, pageSize, getBuilds], ); @@ -67,12 +68,12 @@ export function useBuilds(owner: string, repo: string, branch?: string) { page, pageSize, loading, - value, + builds, projectName, total, }, { - getBuilds, + builds, setPage, setPageSize, restartBuild, diff --git a/packages/core-api/src/apis/implementations/auth/google/types.ts b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts similarity index 65% rename from packages/core-api/src/apis/implementations/auth/google/types.ts rename to plugins/jenkins/src/components/useProjectSlugFromEntity.ts index 3b40932980..1dca918990 100644 --- a/packages/core-api/src/apis/implementations/auth/google/types.ts +++ b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts @@ -13,16 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useEntity } from '@backstage/plugin-catalog'; +import { JENKINS_ANNOTATION } from '../constants'; -import { ProfileInfo, BackstageIdentity } from '../../../definitions'; +export const useProjectSlugFromEntity = () => { + const { entity } = useEntity(); -export type GoogleSession = { - providerInfo: { - idToken: string; - accessToken: string; - scopes: Set; - expiresAt: Date; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; + const [owner, repo] = ( + entity.metadata.annotations?.[JENKINS_ANNOTATION] ?? '' + ).split('/'); + return { owner, repo }; }; diff --git a/plugins/circleci/src/pages/BuildsPage/index.ts b/plugins/jenkins/src/constants.ts similarity index 90% rename from plugins/circleci/src/pages/BuildsPage/index.ts rename to plugins/jenkins/src/constants.ts index 72b46d6bc9..fe8980de73 100644 --- a/plugins/circleci/src/pages/BuildsPage/index.ts +++ b/plugins/jenkins/src/constants.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { default as BuildsPage, Builds } from './BuildsPage'; +export const JENKINS_ANNOTATION = 'jenkins.io/github-folder'; diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index c7f2d8dc28..30fa0c70a5 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -14,5 +14,8 @@ * limitations under the License. */ -export { plugin, JenkinsBuildsWidget, JenkinsLastBuildWidget } from './plugin'; +export { plugin } from './plugin'; +export { LatestRunCard } from './components/Cards'; +export { Router, isPluginApplicableToEntity } from './components/Router'; +export { JENKINS_ANNOTATION } from './constants'; export * from './api'; diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx deleted file mode 100644 index ab4fc3f34f..0000000000 --- a/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { FC, useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { Content, InfoCard, Progress } from '@backstage/core'; -import { Grid, Box, Link, IconButton } from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import { PluginHeader } from '../../components/PluginHeader'; -import { ActionOutput } from './lib/ActionOutput/ActionOutput'; -import { Layout } from '../../components/Layout'; -import LaunchIcon from '@material-ui/icons/Launch'; -import GitHubIcon from '@material-ui/icons/GitHub'; -import { useBuildWithSteps } from '../../state/useBuildWithSteps'; - -const IconLink = IconButton as typeof Link; -const BuildName: FC<{ build?: any }> = ({ build }) => ( - - {build?.buildName} - - {/* TODO use Jenkins logo*/} - - - - - -); -const useStyles = makeStyles(theme => ({ - neutral: {}, - failed: { - position: 'relative', - '&:after': { - pointerEvents: 'none', - content: '""', - position: 'absolute', - top: 0, - right: 0, - left: 0, - bottom: 0, - boxShadow: `inset 4px 0px 0px ${theme.palette.error.main}`, - }, - }, - running: { - position: 'relative', - '&:after': { - pointerEvents: 'none', - content: '""', - position: 'absolute', - top: 0, - right: 0, - left: 0, - bottom: 0, - boxShadow: `inset 4px 0px 0px ${theme.palette.info.main}`, - }, - }, - cardContent: { - backgroundColor: theme.palette.background.default, - }, - success: { - position: 'relative', - '&:after': { - pointerEvents: 'none', - content: '""', - position: 'absolute', - top: 0, - right: 0, - left: 0, - bottom: 0, - boxShadow: `inset 4px 0px 0px ${theme.palette.success.main}`, - }, - }, -})); - -const pickClassName = ( - classes: ReturnType, - build: any = {}, -) => { - if (build.result === 'UNSTABLE') return classes.failed; - if (build.result === 'FAILURE') return classes.failed; - if (build.building) return classes.running; - if (build.status === 'SUCCESS') return classes.success; - - return classes.neutral; -}; - -const Page = () => ( - - - - - -); - -const BuildWithStepsView = () => { - const [searchParams] = useSearchParams(); - const buildPath = searchParams.get('url') || ''; - const classes = useStyles(); - const [{ loading, value }, { startPolling, stopPolling }] = useBuildWithSteps( - buildPath, - ); - - useEffect(() => { - startPolling(); - return () => stopPolling(); - }, [buildPath, startPolling, stopPolling]); - return ( - <> - - - - - } - cardClassName={classes.cardContent} - > - {loading ? : } - - - - - ); -}; - -const BuildsList: FC<{ build?: any }> = ({ build }) => ( - - {build && - build.steps && - build.steps.map(({ name, actions }: { name: string; actions: any[] }) => ( - - ))} - -); - -const ActionsList: FC<{ actions: any[]; name: string }> = ({ actions }) => { - const classes = useStyles(); - return ( - <> - {actions.map((action: any) => ( - - ))} - - ); -}; - -export default Page; -export { BuildWithStepsView as BuildWithSteps }; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx b/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx deleted file mode 100644 index 79f081c82d..0000000000 --- a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { CITable } from '../CITable'; -import { useBuilds } from '../../../../state'; - -export const Builds = ({ owner, repo }: { owner: string; repo: string }) => { - const [ - { total, loading, value, projectName, page, pageSize }, - { setPage, retry, setPageSize }, - ] = useBuilds(owner, repo); - return ( - - ); -}; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/index.ts b/plugins/jenkins/src/pages/BuildsPage/lib/Builds/index.ts deleted file mode 100644 index e91a9496b7..0000000000 --- a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export { Builds } from './Builds'; diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 979b0a31cf..8282f6b7f9 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -14,20 +14,34 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import { DetailedViewPage } from './pages/BuildWithStepsPage'; +import { + createPlugin, + createRouteRef, + createApiFactory, + configApiRef, +} from '@backstage/core'; +import { jenkinsApiRef, JenkinsApi } from './api'; + +export const rootRouteRef = createRouteRef({ + path: '', + title: 'Jenkins', +}); export const buildRouteRef = createRouteRef({ - path: '/jenkins/job', + path: 'run/:branch/:buildNumber', title: 'Jenkins run', }); export const plugin = createPlugin({ id: 'jenkins', - register({ router }) { - router.addRoute(buildRouteRef, DetailedViewPage); - }, + apis: [ + createApiFactory({ + api: jenkinsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + new JenkinsApi( + `${configApi.getString('backend.baseUrl')}/proxy/jenkins/api`, + ), + }), + ], }); - -export { JenkinsBuildsWidget } from './components/JenkinsPluginWidget/JenkinsBuildsWidget'; -export { JenkinsLastBuildWidget } from './components/JenkinsPluginWidget/JenkinsLastBuildWidget'; diff --git a/plugins/jenkins/src/state/index.ts b/plugins/jenkins/src/state/index.ts deleted file mode 100644 index d21a380c2a..0000000000 --- a/plugins/jenkins/src/state/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from './useBuilds'; -export * from './useBuildWithSteps'; diff --git a/plugins/lighthouse/README.md b/plugins/lighthouse/README.md index 337e0f9533..b5fecb85f8 100644 --- a/plugins/lighthouse/README.md +++ b/plugins/lighthouse/README.md @@ -36,16 +36,24 @@ your [`apis.ts`](https://github.com/spotify/backstage/blob/master/packages/app/s ```js import { ApiHolder, ApiRegistry } from '@backstage/core'; +import { Config } from '@backstage/config'; import { lighthouseApiRef, LighthouseRestApi, } from '@backstage/plugin-lighthouse'; -const builder = ApiRegistry.builder(); +export const apis = (config: ConfigApi) => { + const builder = ApiRegistry.builder(); -export const lighthouseApi = - new LighthouseRestApi(/* your service url here! */); -builder.add(lighthouseApiRef, lighthouseApi); + builder.add(lighthouseApiRef, LighthouseRestApi.fromConfig(config)); -export default builder.build() as ApiHolder; + return builder.build() as ApiHolder; +} +``` + +Then configure the lighthouse service url in your [`app-config.yaml`](https://github.com/spotify/backstage/blob/master/app-config.yaml). + +```yaml +lighthouse: + baseUrl: http://your-service-url ``` diff --git a/plugins/lighthouse/dev/index.tsx b/plugins/lighthouse/dev/index.tsx index 6496fb658a..bf761965f1 100644 --- a/plugins/lighthouse/dev/index.tsx +++ b/plugins/lighthouse/dev/index.tsx @@ -20,8 +20,8 @@ import { lighthouseApiRef, LighthouseRestApi } from '../src'; createDevApp() .registerPlugin(plugin) - .registerApiFactory({ - implements: lighthouseApiRef, + .registerApi({ + api: lighthouseApiRef, deps: {}, factory: () => new LighthouseRestApi('http://localhost:3003'), }) diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index cbf4a77f7e..24a106e53d 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,10 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/config": "^0.1.1-alpha.21", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", @@ -33,9 +34,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", - "@backstage/test-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/lighthouse/src/Router.tsx b/plugins/lighthouse/src/Router.tsx new file mode 100644 index 0000000000..46843504fd --- /dev/null +++ b/plugins/lighthouse/src/Router.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Routes, Route } from 'react-router-dom'; +import { rootRouteRef, viewAuditRouteRef, createAuditRouteRef } from './plugin'; +import AuditList from './components/AuditList'; +import AuditView from './components/AuditView'; +import CreateAudit from './components/CreateAudit'; + +export const Router = () => ( + + } /> + } /> + } /> + +); diff --git a/plugins/lighthouse/src/api.ts b/plugins/lighthouse/src/api.ts index 9c656c88ef..5b6cd9aae7 100644 --- a/plugins/lighthouse/src/api.ts +++ b/plugins/lighthouse/src/api.ts @@ -15,6 +15,7 @@ */ import { createApiRef } from '@backstage/core'; +import { Config } from '@backstage/config'; export type LighthouseCategoryId = | 'pwa' @@ -111,6 +112,10 @@ export const lighthouseApiRef = createApiRef({ }); export class LighthouseRestApi implements LighthouseApi { + static fromConfig(config: Config) { + return new LighthouseRestApi(config.getString('lighthouse.baseUrl')); + } + constructor(public url: string) {} private async fetch(input: string, init?: RequestInit): Promise { diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx index 039b397083..e67f939ba1 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx @@ -58,10 +58,7 @@ describe('AuditListTable', () => { if (!website) throw new Error('https://anchor.fm must be present in fixture'); expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute( - 'href', - `/lighthouse/audit/${website.lastAudit.id}`, - ); + expect(link).toHaveAttribute('href', `/audit/${website.lastAudit.id}`); }); it('renders the dates that are available for a given row', () => { diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx index 20dd1f39f8..332dd5e3ca 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, useState } from 'react'; +import React, { FC, useState, useEffect } from 'react'; import { Table, TableColumn, TrendLine, useApi } from '@backstage/core'; import { Website, lighthouseApiRef } from '../../api'; import { useInterval } from 'react-use'; @@ -23,15 +23,16 @@ import { CATEGORY_LABELS, buildSparklinesDataForItem, } from '../../utils'; -import { Link } from '@material-ui/core'; +import { Link, generatePath } from 'react-router-dom'; import AuditStatusIcon from '../AuditStatusIcon'; +import { viewAuditRouteRef } from '../../plugin'; const columns: TableColumn[] = [ { title: 'Website URL', field: 'websiteUrl', }, - ...CATEGORIES.map((category) => ({ + ...CATEGORIES.map(category => ({ title: CATEGORY_LABELS[category], field: category, })), @@ -55,8 +56,12 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => { const [websiteState, setWebsiteState] = useState(items); const lighthouseApi = useApi(lighthouseApiRef); + useEffect(() => { + setWebsiteState(items); + }, [items]); + const runRefresh = (websites: Website[]) => { - websites.forEach(async (website) => { + websites.forEach(async website => { const response = await lighthouseApi.getWebsiteForAuditId( website.lastAudit.id, ); @@ -64,7 +69,7 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => { if (auditStatus === 'COMPLETED' || auditStatus === 'FAILED') { const newWebsiteData = websiteState.slice(0); newWebsiteData[ - newWebsiteData.findIndex((w) => w.url === response.url) + newWebsiteData.findIndex(w => w.url === response.url) ] = response; setWebsiteState(newWebsiteData); } @@ -72,7 +77,7 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => { }; const runningWebsiteAudits = websiteState - ? websiteState.filter((website) => website.lastAudit.status === 'RUNNING') + ? websiteState.filter(website => website.lastAudit.status === 'RUNNING') : []; useInterval( @@ -80,10 +85,10 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => { runningWebsiteAudits.length > 0 ? 5000 : null, ); - const data = websiteState.map((website) => { + const data = websiteState.map(website => { const trendlineData = buildSparklinesDataForItem(website); const trendlines: any = {}; - CATEGORIES.forEach((category) => { + CATEGORIES.forEach(category => { trendlines[category] = ( = ({ items }) => { return { websiteUrl: ( - + {website.url} ), diff --git a/plugins/lighthouse/src/components/AuditList/index.test.tsx b/plugins/lighthouse/src/components/AuditList/index.test.tsx index 076bb5819a..34fd760801 100644 --- a/plugins/lighthouse/src/components/AuditList/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.test.tsx @@ -63,7 +63,7 @@ describe('AuditList', () => { expect(element).toBeInTheDocument(); }); - it('renders a link to create a new audit', async () => { + it('renders a button to create a new audit', async () => { const rendered = render( wrapInTestApp( @@ -71,12 +71,8 @@ describe('AuditList', () => { , ), ); - const element = await rendered.findByText('Create Audit'); - expect(element).toBeInTheDocument(); - expect(element.parentElement).toHaveAttribute( - 'href', - '/lighthouse/create-audit', - ); + const button = await rendered.findByText('Create Audit'); + expect(button).toBeInTheDocument(); }); describe('pagination', () => { @@ -87,7 +83,7 @@ describe('AuditList', () => { , - { routeEntries: ['/lighthouse?page=2'] }, + { routeEntries: ['?page=2'] }, ), ); expect(mockFetch).toHaveBeenLastCalledWith( @@ -137,13 +133,13 @@ describe('AuditList', () => { , - { routeEntries: ['/lighthouse?page=2'] }, + { routeEntries: ['?page=2'] }, ), ); const element = await rendered.findByLabelText(/Go to page 1/); fireEvent.click(element); - expect(useNavigate()).toHaveBeenCalledWith(`/lighthouse?page=1`); + expect(useNavigate()).toHaveBeenCalledWith(`?page=1`); }); }); }); diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx index c83ce9f641..a75bb25a0d 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -37,6 +37,7 @@ import { useQuery } from '../../utils'; import LighthouseSupportButton from '../SupportButton'; import LighthouseIntro, { LIGHTHOUSE_INTRO_LOCAL_STORAGE } from '../Intro'; import AuditListTable from './AuditListTable'; +import { createAuditRouteRef } from '../../plugin'; export const LIMIT = 10; @@ -77,7 +78,7 @@ const AuditList: FC<{}> = () => { page={page} count={pageCount} onChange={(_event: Event, newPage: number) => { - navigate(`/lighthouse?page=${newPage}`); + navigate(`?page=${newPage}`); }} /> )} @@ -111,7 +112,7 @@ const AuditList: FC<{}> = () => { diff --git a/plugins/lighthouse/src/components/AuditView/index.test.tsx b/plugins/lighthouse/src/components/AuditView/index.test.tsx index b2c15cd8b7..742be67fed 100644 --- a/plugins/lighthouse/src/components/AuditView/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.test.tsx @@ -18,15 +18,17 @@ jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); + const mockNavigation = jest.fn(); return { ...actual, useParams: jest.fn(() => ({})), + useNavigate: jest.fn(() => mockNavigation), }; }); import React from 'react'; import mockFetch from 'jest-fetch-mock'; -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; import { ApiRegistry, ApiProvider } from '@backstage/core'; @@ -34,11 +36,13 @@ import AuditView from '.'; import { lighthouseApiRef, LighthouseRestApi, Audit, Website } from '../../api'; import { formatTime } from '../../utils'; import * as data from '../../__fixtures__/website-response.json'; +import { act } from 'react-dom/test-utils'; const { useParams }: { useParams: jest.Mock } = jest.requireMock( 'react-router-dom', ); const websiteResponse = data as Website; +const { useNavigate } = jest.requireMock('react-router-dom'); describe('AuditView', () => { let apis: ApiRegistry; @@ -70,7 +74,7 @@ describe('AuditView', () => { expect(iframe).toHaveAttribute('src', `https://lighthouse/v1/audits/${id}`); }); - it('renders a link to create a new audit for this website', async () => { + it('renders a button to click to create a new audit for this website', async () => { const rendered = render( wrapInTestApp( @@ -79,13 +83,15 @@ describe('AuditView', () => { ), ); - const button = await rendered.findByText('Create Audit'); + const button = await rendered.findByText('Create New Audit'); expect(button).toBeInTheDocument(); - expect(button.parentElement).toHaveAttribute( - 'href', - `/lighthouse/create-audit?url=${encodeURIComponent( - 'https://spotify.com', - )}`, + + act(() => { + fireEvent.click(button); + }); + + expect(useNavigate()).toHaveBeenCalledWith( + `../../create-audit?url=${encodeURIComponent('https://spotify.com')}`, ); }); @@ -151,7 +157,7 @@ describe('AuditView', () => { expect( rendered.getByText(formatTime(a.timeCreated)).parentElement ?.parentElement, - ).toHaveAttribute('href', `/lighthouse/audit/${a.id}`); + ).toHaveAttribute('href', `/audit/${a.id}`); }); }); }); diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx index 12606482bf..d0e743a77f 100644 --- a/plugins/lighthouse/src/components/AuditView/index.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.tsx @@ -14,7 +14,13 @@ * limitations under the License. */ import React, { useState, useEffect, ReactNode, FC } from 'react'; -import { Link, useParams } from 'react-router-dom'; +import { + Link, + useParams, + useNavigate, + generatePath, + resolvePath, +} from 'react-router-dom'; import { useAsync } from 'react-use'; import { makeStyles, @@ -42,6 +48,7 @@ import { lighthouseApiRef, Website, Audit } from '../../api'; import AuditStatusIcon from '../AuditStatusIcon'; import LighthouseSupportButton from '../SupportButton'; import { formatTime } from '../../utils'; +import { viewAuditRouteRef, createAuditRouteRef } from '../../plugin'; const useStyles = makeStyles({ contentGrid: { @@ -75,7 +82,12 @@ const AuditLinkList: FC = ({ button component={Link} replace - to={`/lighthouse/audit/${audit.id}`} + to={resolvePath( + generatePath(viewAuditRouteRef.path, { + id: audit.id, + }), + '../../', + )} > @@ -116,6 +128,7 @@ const ConnectedAuditView: FC<{}> = () => { const lighthouseApi = useApi(lighthouseApiRef); const params = useParams() as { id: string }; const classes = useStyles(); + const navigate = useNavigate(); const { loading, error, value: nextValue } = useAsync( async () => await lighthouseApi.getWebsiteForAuditId(params.id), @@ -154,7 +167,7 @@ const ConnectedAuditView: FC<{}> = () => { ); } - let createAuditButtonUrl = '/lighthouse/create-audit'; + let createAuditButtonUrl = createAuditRouteRef.path; if (value?.url) { createAuditButtonUrl += `?url=${encodeURIComponent(value.url)}`; } @@ -176,9 +189,9 @@ const ConnectedAuditView: FC<{}> = () => { diff --git a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx index aaf376923f..b2348bf5ff 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx @@ -78,9 +78,7 @@ describe('CreateAudit', () => { , { - routeEntries: [ - `/lighthouse/create-audit?url=${encodeURIComponent(url)}`, - ], + routeEntries: [`/create-audit?url=${encodeURIComponent(url)}`], }, ), ); @@ -137,7 +135,7 @@ describe('CreateAudit', () => { await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled()); - expect(useNavigate()).toHaveBeenCalledWith('/lighthouse'); + expect(useNavigate()).toHaveBeenCalledWith('..'); }); }); diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx index a699e148c8..36bd97485a 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx @@ -78,7 +78,7 @@ const CreateAudit: FC<{}> = () => { }, }, }); - navigate('/lighthouse'); + navigate('..'); } catch (err) { errorApi.post(err); } finally { @@ -154,7 +154,7 @@ const CreateAudit: FC<{}> = () => { diff --git a/plugins/register-component/src/components/Router.tsx b/plugins/register-component/src/components/Router.tsx new file mode 100644 index 0000000000..eee7fd3806 --- /dev/null +++ b/plugins/register-component/src/components/Router.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Route, Routes } from 'react-router'; +import { RegisterComponentPage } from './RegisterComponentPage'; +import { RouteRef } from '@backstage/core'; + +// As we don't know which path the catalog's router mounted on +// We need to inject this from the app +export const Router = ({ catalogRouteRef }: { catalogRouteRef: RouteRef }) => ( + + } + /> + +); diff --git a/plugins/register-component/src/index.ts b/plugins/register-component/src/index.ts index 5b20cb0158..ff7857cacd 100644 --- a/plugins/register-component/src/index.ts +++ b/plugins/register-component/src/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export { plugin, rootRoute } from './plugin'; +export { plugin } from './plugin'; +export { Router } from './components/Router'; diff --git a/plugins/register-component/src/plugin.ts b/plugins/register-component/src/plugin.ts index 9c73688a70..17f99917c8 100644 --- a/plugins/register-component/src/plugin.ts +++ b/plugins/register-component/src/plugin.ts @@ -14,18 +14,8 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import RegisterComponentPage from './components/RegisterComponentPage'; - -export const rootRoute = createRouteRef({ - icon: () => null, - path: '/register-component', - title: 'Register component', -}); +import { createPlugin } from '@backstage/core'; export const plugin = createPlugin({ id: 'register-component', - register({ router }) { - router.addRoute(rootRoute, RegisterComponentPage); - }, }); diff --git a/plugins/register-component/src/util/validate.test.ts b/plugins/register-component/src/util/validate.test.ts index d062655bd2..2b465a536e 100644 --- a/plugins/register-component/src/util/validate.test.ts +++ b/plugins/register-component/src/util/validate.test.ts @@ -31,11 +31,13 @@ describe('ComponentIdValidators', () => { }); }); describe('yamlValidator', () => { - const errorMessage = "Must end with '.yaml'."; + const errorMessage = "Must contain '.yaml'."; test.each([ [true, '.yaml'], [true, 'http://example.com/blob/master/service.yaml'], [true, 'https://example.yaml'], + [true, 'https://example.com?path=abc.yaml&c=1'], + [errorMessage, 'https://example.com?path=abc_yaml&c=1'], [errorMessage, '.yml'], [errorMessage, 'http://example.com/blob/master/service'], [errorMessage, undefined], diff --git a/plugins/register-component/src/util/validate.ts b/plugins/register-component/src/util/validate.ts index 78d20995f5..9afb5ebd97 100644 --- a/plugins/register-component/src/util/validate.ts +++ b/plugins/register-component/src/util/validate.ts @@ -19,6 +19,6 @@ export const ComponentIdValidators = { (typeof value === 'string' && value.match(/^https:\/\//) !== null) || 'Must start with https://.', yamlValidator: (value: any) => - (typeof value === 'string' && value.match(/.yaml$/) !== null) || - "Must end with '.yaml'.", + (typeof value === 'string' && value.match(/\.yaml/) !== null) || + "Must contain '.yaml'.", }; diff --git a/plugins/rollbar-backend/README.md b/plugins/rollbar-backend/README.md index 8cd08abed3..c221dfd23b 100644 --- a/plugins/rollbar-backend/README.md +++ b/plugins/rollbar-backend/README.md @@ -19,5 +19,5 @@ access account token._ ## Links -- (Frontend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/rollbar] -- (The Backstage homepage)[https://backstage.io] +- [Frontend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/rollbar) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 4b4a45b9dd..96a968ee46 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar-backend", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,10 +20,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.18", - "@backstage/config": "^0.1.1-alpha.18", + "@backstage/backend-common": "^0.1.1-alpha.21", + "@backstage/config": "^0.1.1-alpha.21", "@types/express": "^4.17.6", - "axios": "^0.19.2", + "axios": "^0.20.0", "camelcase-keys": "^6.2.2", "compression": "^1.7.4", "cors": "^2.8.5", @@ -37,7 +37,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", "@types/supertest": "^2.0.8", "jest-fetch-mock": "^3.0.3", "supertest": "^4.0.2" diff --git a/plugins/rollbar-backend/src/service/router.ts b/plugins/rollbar-backend/src/service/router.ts index 0a8c58a9d7..5ee63b5b05 100644 --- a/plugins/rollbar-backend/src/service/router.ts +++ b/plugins/rollbar-backend/src/service/router.ts @@ -31,6 +31,7 @@ export async function createRouter( options: RouterOptions, ): Promise { const router = Router(); + const logger = options.logger.child({ plugin: 'rollbar' }); const config = options.config.getConfig('rollbar'); const accessToken = !options.rollbarApi diff --git a/plugins/rollbar/README.md b/plugins/rollbar/README.md index d0cfae00cc..b2d2100dd9 100644 --- a/plugins/rollbar/README.md +++ b/plugins/rollbar/README.md @@ -26,25 +26,57 @@ export { plugin as Rollbar } from '@backstage/plugin-rollbar'; import { RollbarClient, rollbarApiRef } from '@backstage/plugin-rollbar'; // ... - -builder.add( - rollbarApiRef, - new RollbarClient({ - apiOrigin: backendUrl, - basePath: '/rollbar', - }), -); - -// Alternatively you can use the mock client -// builder.add(rollbarApiRef, new RollbarMockClient()); +builder.add(rollbarApiRef, new RollbarClient({ discoveryApi })); ``` -5. Run app with `yarn start` and navigate to `/rollbar` +5. Add to the app `EntityPage` component: + +```ts +// packages/app/src/components/catalog/EntityPage.tsx +import { Router as RollbarRouter } from '@backstage/plugin-rollbar'; + +// ... +const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( + + // ... + } + /> + +); +``` + +6. Setup the `app.config.yaml` and account token environment variable + +```yaml +# app.config.yaml +rollbar: + organization: spotify + accountToken: + $secret: + env: ROLLBAR_ACCOUNT_TOKEN +``` + +7. Annotate entities with the rollbar project slug + +```yaml +# pump-station-catalog-component.yaml +# ... +metadata: + annotations: + rollbar.com/project-slug: organization-name/project-name + # -- or just --- + rollbar.com/project-slug: project-name +``` + +8. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity ## Features -- List rollbar projects -- View top active items for each project +- List rollbar entities that are annotated with `rollbar.com/project-slug` +- View top active items for each rollbar annotated entity ## Limitations @@ -52,5 +84,5 @@ builder.add( ## Links -- (Backend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/rollbar-backend] -- (The Backstage homepage)[https://backstage.io] +- [Backend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/rollbar-backend) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 1ca02cb528..ae9d0c5bc5 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/plugin-catalog": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "lodash": "^4.17.15", @@ -35,9 +37,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", - "@backstage/test-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", diff --git a/plugins/rollbar/src/api/RollbarApi.ts b/plugins/rollbar/src/api/RollbarApi.ts index c08bc08d48..bde6c5e9f2 100644 --- a/plugins/rollbar/src/api/RollbarApi.ts +++ b/plugins/rollbar/src/api/RollbarApi.ts @@ -29,6 +29,7 @@ export const rollbarApiRef = createApiRef({ export interface RollbarApi { getAllProjects(): Promise; + getProject(projectName: string): Promise; getTopActiveItems( project: string, hours?: number, diff --git a/plugins/rollbar/src/api/RollbarClient.ts b/plugins/rollbar/src/api/RollbarClient.ts index 5cabbcbc24..e612890655 100644 --- a/plugins/rollbar/src/api/RollbarClient.ts +++ b/plugins/rollbar/src/api/RollbarClient.ts @@ -20,26 +20,21 @@ import { RollbarProject, RollbarTopActiveItem, } from './types'; +import { DiscoveryApi } from '@backstage/core'; export class RollbarClient implements RollbarApi { - private apiOrigin: string; - private basePath: string; + private readonly discoveryApi: DiscoveryApi; - constructor({ - apiOrigin, - basePath, - }: { - apiOrigin: string; - basePath: string; - }) { - this.apiOrigin = apiOrigin; - this.basePath = basePath; + constructor(options: { discoveryApi: DiscoveryApi }) { + this.discoveryApi = options.discoveryApi; } async getAllProjects(): Promise { - const path = `/projects`; + return await this.get(`/projects`); + } - return await this.get(path); + async getProject(projectName: string): Promise { + return await this.get(`/projects/${projectName}`); } async getTopActiveItems( @@ -47,19 +42,17 @@ export class RollbarClient implements RollbarApi { hours = 24, environment = 'production', ): Promise { - const path = `/projects/${project}/top_active_items?environment=${environment}&hours=${hours}`; - - return await this.get(path); + return await this.get( + `/projects/${project}/top_active_items?environment=${environment}&hours=${hours}`, + ); } async getProjectItems(project: string): Promise { - const path = `/projects/${project}/items`; - - return await this.get(path); + return await this.get(`/projects/${project}/items`); } private async get(path: string): Promise { - const url = `${this.apiOrigin}${this.basePath}${path}`; + const url = `${await this.discoveryApi.getBaseUrl('rollbar')}${path}`; const response = await fetch(url); if (!response.ok) { diff --git a/plugins/rollbar/src/api/RollbarMockClient.ts b/plugins/rollbar/src/api/RollbarMockClient.ts deleted file mode 100644 index cab9a0d23a..0000000000 --- a/plugins/rollbar/src/api/RollbarMockClient.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* eslint-disable @typescript-eslint/no-unused-vars */ - -import { RollbarApi } from './RollbarApi'; -import { - RollbarItemsResponse, - RollbarProject, - RollbarTopActiveItem, -} from './types'; - -export class RollbarMockClient implements RollbarApi { - async getAllProjects(): Promise { - return Promise.resolve([ - { id: 123, name: 'project-a', accountId: 1, status: 'enabled' }, - { id: 356, name: 'project-b', accountId: 1, status: 'enabled' }, - { id: 789, name: 'project-c', accountId: 1, status: 'enabled' }, - ]); - } - - async getTopActiveItems( - _project: string, - _hours = 24, - _environment = 'production', - ): Promise { - const createItem = (id: number): RollbarTopActiveItem => ({ - item: { - id, - counter: id, - environment: 'production', - framework: 2, - lastOccurrenceTimestamp: new Date().getTime() / 1000, - level: 50, - occurrences: 100, - projectId: 12345, - title: `Some error occurred in service - ${id}`, - uniqueOccurrences: 10, - }, - counts: Array.from({ length: 168 }, () => - Math.floor(Math.random() * 100), - ), - }); - - const items = Array.from({ length: 10 }, (_, i) => createItem(i)); - - return Promise.resolve(items); - } - - async getProjectItems(_project: string): Promise { - return Promise.resolve({ - items: [], - page: 0, - totalCount: 0, - }); - } -} diff --git a/plugins/rollbar/src/api/index.ts b/plugins/rollbar/src/api/index.ts new file mode 100644 index 0000000000..f45ff5bf8a --- /dev/null +++ b/plugins/rollbar/src/api/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './RollbarApi'; +export * from './RollbarClient'; diff --git a/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx b/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx similarity index 71% rename from plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx rename to plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx index 57082a9c91..888b9aabe0 100644 --- a/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx +++ b/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx @@ -14,16 +14,14 @@ * limitations under the License. */ +import React from 'react'; import { Entity } from '@backstage/catalog-model'; -import { InfoCard, StructuredMetadataTable } from '@backstage/core'; -import React, { FC } from 'react'; +import { RollbarProject } from '../RollbarProject/RollbarProject'; type Props = { entity: Entity; }; -export const EntityMetadataCard: FC = ({ entity }) => ( - - - -); +export const EntityPageRollbar = ({ entity }: Props) => { + return ; +}; diff --git a/plugins/rollbar/src/components/RollbarPage/RollbarPage.test.tsx b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx similarity index 80% rename from plugins/rollbar/src/components/RollbarPage/RollbarPage.test.tsx rename to plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx index 11263ed820..c6ee052c0e 100644 --- a/plugins/rollbar/src/components/RollbarPage/RollbarPage.test.tsx +++ b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx @@ -21,13 +21,14 @@ import { ConfigApi, configApiRef, } from '@backstage/core'; +import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi'; import { RollbarProject } from '../../api/types'; -import { RollbarPage } from './RollbarPage'; +import { RollbarHome } from './RollbarHome'; -describe('RollbarPage component', () => { +describe('RollbarHome component', () => { const projects: RollbarProject[] = [ { id: 123, name: 'abc', accountId: 1, status: 'enabled' }, { id: 456, name: 'xyz', accountId: 1, status: 'enabled' }, @@ -47,6 +48,14 @@ describe('RollbarPage component', () => { apis={ApiRegistry.from([ [rollbarApiRef, rollbarApi], [configApiRef, config], + [ + catalogApiRef, + ({ + async getEntities() { + return []; + }, + } as Partial) as CatalogApi, + ], ])} > {children} @@ -55,7 +64,7 @@ describe('RollbarPage component', () => { ); it('should render rollbar landing page', async () => { - const rendered = renderWrapped(); + const rendered = renderWrapped(); expect(rendered.getByText(/Rollbar/)).toBeInTheDocument(); }); }); diff --git a/plugins/circleci/src/components/Layout/Layout.tsx b/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx similarity index 53% rename from plugins/circleci/src/components/Layout/Layout.tsx rename to plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx index 09479e6ef0..d09c2054b1 100644 --- a/plugins/circleci/src/components/Layout/Layout.tsx +++ b/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx @@ -13,17 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { Header, Page, pageTheme, HeaderLabel } from '@backstage/core'; -export const Layout: React.FC = ({ children }) => { +import React from 'react'; +import { Content, Header, Page, pageTheme } from '@backstage/core'; +import { RollbarProjectTable } from '../RollbarProjectTable/RollbarProjectTable'; +import { useRollbarEntities } from '../../hooks/useRollbarEntities'; + +export const RollbarHome = () => { + const { entities, loading, error } = useRollbarEntities(); + return ( -
- - -
- {children} +
+ + + ); }; diff --git a/plugins/rollbar/src/components/RollbarLayout/RollbarLayout.tsx b/plugins/rollbar/src/components/RollbarLayout/RollbarLayout.tsx deleted file mode 100644 index c0524fabc5..0000000000 --- a/plugins/rollbar/src/components/RollbarLayout/RollbarLayout.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { ReactNode } from 'react'; -import { - Header, - HeaderLabel, - Page, - pageTheme, - Content, - ContentHeader, - SupportButton, -} from '@backstage/core'; -import { Grid } from '@material-ui/core'; - -type Props = { - title?: string; - children: ReactNode; -}; - -export const RollbarLayout = ({ title = 'Dashboard', children }: Props) => { - return ( - -
- - -
- - - - Rollbar plugin allows you to preview issues and navigate to rollbar. - - - - {children} - - -
- ); -}; diff --git a/plugins/rollbar/src/components/RollbarPage/RollbarProjectTable.tsx b/plugins/rollbar/src/components/RollbarPage/RollbarProjectTable.tsx deleted file mode 100644 index fdf90c468e..0000000000 --- a/plugins/rollbar/src/components/RollbarPage/RollbarProjectTable.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Link as RouterLink } from 'react-router-dom'; -import { Link } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import OpenInNewIcon from '@material-ui/icons/OpenInNew'; -import { Table, TableColumn } from '@backstage/core'; -import { RollbarProject } from '../../api/types'; - -const projectUrl = (org: string, id: number) => - `https://rollbar.com/${org}/all/items/?projects=${id}`; - -const columns: TableColumn[] = [ - { - title: 'ID', - field: 'id', - type: 'numeric', - align: 'left', - width: '100px', - }, - { - title: 'Name', - field: 'name', - type: 'string', - highlight: true, - render: (row: Partial) => ( - - {row.name} - - ), - }, - { - title: 'Status', - field: 'status', - type: 'string', - }, - { - title: 'Open', - width: '10%', - render: (row: any) => ( - - - - ), - }, -]; - -type Props = { - projects: RollbarProject[]; - loading: boolean; - organization: string; - error?: any; -}; - -export const RollbarProjectTable = ({ - projects, - organization, - loading, - error, -}: Props) => { - if (error) { - return ( -
- - Error encountered while fetching rollbar projects. {error.toString()} - -
- ); - } - - return ( -
({ organization, ...p }))} - /> - ); -}; diff --git a/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx b/plugins/rollbar/src/components/RollbarProject/RollbarProject.tsx similarity index 56% rename from plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx rename to plugins/rollbar/src/components/RollbarProject/RollbarProject.tsx index 4bb64f221d..34881ba560 100644 --- a/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx +++ b/plugins/rollbar/src/components/RollbarProject/RollbarProject.tsx @@ -14,19 +14,27 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; -import { render } from '@testing-library/react'; import React from 'react'; -import { EntityMetadataCard } from './EntityMetadataCard'; +import { Entity } from '@backstage/catalog-model'; +import { useTopActiveItems } from '../../hooks/useTopActiveItems'; +import { RollbarTopItemsTable } from '../RollbarTopItemsTable/RollbarTopItemsTable'; -describe('EntityMetadataCard component', () => { - it('should display entity name if provided', async () => { - const testEntity: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { name: 'test' }, - }; - const rendered = await render(); - expect(await rendered.findByText('test')).toBeInTheDocument(); - }); -}); +type Props = { + entity: Entity; +}; + +export const RollbarProject = ({ entity }: Props) => { + const { items, organization, project, loading, error } = useTopActiveItems( + entity, + ); + + return ( + + ); +}; diff --git a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx index e4919a68bd..ad3d4a554d 100644 --- a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx +++ b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx @@ -26,6 +26,7 @@ import { render } from '@testing-library/react'; import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi'; import { RollbarTopActiveItem } from '../../api/types'; import { RollbarProjectPage } from './RollbarProjectPage'; +import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; describe('RollbarProjectPage component', () => { const items: RollbarTopActiveItem[] = [ @@ -60,6 +61,17 @@ describe('RollbarProjectPage component', () => { apis={ApiRegistry.from([ [rollbarApiRef, rollbarApi], [configApiRef, config], + [ + catalogApiRef, + ({ + async getEntityByName() { + return { + metadata: { name: 'foo' }, + spec: { owner: 'bar', lifecycle: 'experimental' }, + } as any; + }, + } as Partial) as CatalogApi, + ], ])} > {children} @@ -69,6 +81,6 @@ describe('RollbarProjectPage component', () => { it('should render rollbar project page', async () => { const rendered = renderWrapped(); - expect(rendered.getByText(/Top Active Items/)).toBeInTheDocument(); + expect(rendered.getByText(/Rollbar/)).toBeInTheDocument(); }); }); diff --git a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.tsx b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.tsx index 6635a72086..b18357e310 100644 --- a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.tsx +++ b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.tsx @@ -15,39 +15,22 @@ */ import React from 'react'; -import { useParams } from 'react-router-dom'; -import { useAsync } from 'react-use'; -import { configApiRef, useApi } from '@backstage/core'; -import { rollbarApiRef } from '../../api/RollbarApi'; -import { RollbarLayout } from '../RollbarLayout/RollbarLayout'; -import { RollbarTopItemsTable } from '../RollbarTopItemsTable/RollbarTopItemsTable'; +import { Content, Header, HeaderLabel, Page, pageTheme } from '@backstage/core'; +import { useCatalogEntity } from '../../hooks/useCatalogEntity'; +import { RollbarProject } from '../RollbarProject/RollbarProject'; export const RollbarProjectPage = () => { - const configApi = useApi(configApiRef); - const rollbarApi = useApi(rollbarApiRef); - const org = - configApi.getOptionalString('rollbar.organization') ?? - configApi.getString('organization.name'); - const { componentId } = useParams() as { - componentId: string; - }; - const { value, loading, error } = useAsync(() => - rollbarApi - .getTopActiveItems(componentId, 168) - .then(data => - data.sort((a, b) => b.item.occurrences - a.item.occurrences), - ), - ); + const { entity } = useCatalogEntity(); return ( - - - + +
+ + +
+ + {entity ? : 'Loading'} + +
); }; diff --git a/plugins/api-docs/src/components/ApiCatalogTable/ApiCatalogTable.tsx b/plugins/rollbar/src/components/RollbarProjectTable/RollbarProjectTable.tsx similarity index 73% rename from plugins/api-docs/src/components/ApiCatalogTable/ApiCatalogTable.tsx rename to plugins/rollbar/src/components/RollbarProjectTable/RollbarProjectTable.tsx index e30583caed..5b3631ade9 100644 --- a/plugins/api-docs/src/components/ApiCatalogTable/ApiCatalogTable.tsx +++ b/plugins/rollbar/src/components/RollbarProjectTable/RollbarProjectTable.tsx @@ -13,23 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; + +import React from 'react'; +import { Link as RouterLink, generatePath } from 'react-router-dom'; import { Table, TableColumn } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; import { Link } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import React from 'react'; -import { generatePath, Link as RouterLink } from 'react-router-dom'; -import { entityRoute } from '../../routes'; +import { entityRouteRef } from '../../routes'; -const columns: TableColumn[] = [ +const columns: TableColumn[] = [ { title: 'Name', field: 'metadata.name', + type: 'string', highlight: true, render: (entity: any) => ( [] = [ }, ]; -type CatalogTableProps = { +type Props = { entities: Entity[]; - titlePreamble: string; loading: boolean; error?: any; }; -export const ApiCatalogTable = ({ - entities, - loading, - error, - titlePreamble, -}: CatalogTableProps) => { +export const RollbarProjectTable = ({ entities, loading, error }: Props) => { if (error) { return (
- Error encountered while fetching catalog entities. {error.toString()} + Error encountered while fetching rollbar projects. {error.toString()}
); } return ( - +
); diff --git a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx index 913ecca28a..a04155a0c3 100644 --- a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx +++ b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx @@ -16,17 +16,15 @@ import React from 'react'; import { Table, TableColumn } from '@backstage/core'; -import { Link } from '@material-ui/core'; +import { Box, Link, Typography } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import { RollbarFrameworkId, RollbarLevel, RollbarTopActiveItem, } from '../../api/types'; -import { RollbarTrendGraph } from '../RollbarTrendGraph/RollbarTrendGraph'; - -const itemUrl = (org: string, project: string, id: number) => - `https://rollbar.com/${org}/${project}/items/${id}`; +import { buildItemUrl } from '../../utils'; +import { TrendGraph } from '../TrendGraph/TrendGraph'; const columns: TableColumn[] = [ { @@ -37,7 +35,7 @@ const columns: TableColumn[] = [ width: '70px', render: (data: any) => ( @@ -54,7 +52,7 @@ const columns: TableColumn[] = [ { title: 'Trend', sorting: false, - render: (data: any) => , + render: (data: any) => , }, { title: 'Occurrences', @@ -127,7 +125,12 @@ export const RollbarTopItemsTable = ({ pageSize: 5, showEmptyDataSourceMessage: !loading, }} - title="Top Active Items" + title={ + + + Top Active Items / {project} + + } data={items.map(i => ({ org: organization, project, ...i }))} /> ); diff --git a/plugins/rollbar/src/components/Router.tsx b/plugins/rollbar/src/components/Router.tsx new file mode 100644 index 0000000000..bcf325f281 --- /dev/null +++ b/plugins/rollbar/src/components/Router.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Routes, Route } from 'react-router'; +import { Entity } from '@backstage/catalog-model'; +import { WarningPanel } from '@backstage/core'; +import { catalogRouteRef } from '../routes'; +import { ROLLBAR_ANNOTATION } from '../constants'; +import { EntityPageRollbar } from './EntityPageRollbar/EntityPageRollbar'; + +export const isPluginApplicableToEntity = (entity: Entity) => + entity.metadata.annotations?.[ROLLBAR_ANNOTATION] !== ''; + +type Props = { + entity: Entity; +}; + +export const Router = ({ entity }: Props) => + !isPluginApplicableToEntity(entity) ? ( + +
{ROLLBAR_ANNOTATION}
annotation is missing on the entity. +
+ ) : ( + + } + /> + + ); diff --git a/plugins/rollbar/src/components/RollbarTrendGraph/RollbarTrendGraph.test.tsx b/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx similarity index 81% rename from plugins/rollbar/src/components/RollbarTrendGraph/RollbarTrendGraph.test.tsx rename to plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx index 1f9d317e5b..ac1a328521 100644 --- a/plugins/rollbar/src/components/RollbarTrendGraph/RollbarTrendGraph.test.tsx +++ b/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx @@ -17,15 +17,13 @@ import React from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; -import { RollbarTrendGraph } from './RollbarTrendGraph'; +import { TrendGraph } from './TrendGraph'; -describe('RollbarTrendGraph component', () => { +describe('TrendGraph component', () => { it('should render a trend graph sparkline', async () => { const mockCounts = [1, 2, 3, 4]; const rendered = render( - wrapInTestApp( - , - ), + wrapInTestApp(), ); expect(rendered).toBeTruthy(); }); diff --git a/plugins/rollbar/src/components/RollbarTrendGraph/RollbarTrendGraph.tsx b/plugins/rollbar/src/components/TrendGraph/TrendGraph.tsx similarity index 93% rename from plugins/rollbar/src/components/RollbarTrendGraph/RollbarTrendGraph.tsx rename to plugins/rollbar/src/components/TrendGraph/TrendGraph.tsx index 4f0b53eb4a..5d518817bc 100644 --- a/plugins/rollbar/src/components/RollbarTrendGraph/RollbarTrendGraph.tsx +++ b/plugins/rollbar/src/components/TrendGraph/TrendGraph.tsx @@ -21,7 +21,7 @@ type Props = { counts: number[]; }; -export const RollbarTrendGraph = ({ counts }: Props) => { +export const TrendGraph = ({ counts }: Props) => { return ( diff --git a/plugins/rollbar/src/constants.ts b/plugins/rollbar/src/constants.ts new file mode 100644 index 0000000000..e3a89d655e --- /dev/null +++ b/plugins/rollbar/src/constants.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const ROLLBAR_ANNOTATION = 'rollbar.com/project-slug'; diff --git a/packages/core-api/src/apis/implementations/auth/okta/types.ts b/plugins/rollbar/src/hooks/useCatalogEntity.ts similarity index 55% rename from packages/core-api/src/apis/implementations/auth/okta/types.ts rename to plugins/rollbar/src/hooks/useCatalogEntity.ts index e3392ba1d6..172d057738 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/types.ts +++ b/plugins/rollbar/src/hooks/useCatalogEntity.ts @@ -13,15 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ProfileInfo, BackstageIdentity } from '../../../definitions'; -export type OktaSession = { - providerInfo: { - idToken: string; - accessToken: string; - scopes: Set; - expiresAt: Date; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; +import { useAsync } from 'react-use'; +import { useApi } from '@backstage/core'; +import { + catalogApiRef, + useEntityCompoundName, +} from '@backstage/plugin-catalog'; + +export function useCatalogEntity() { + const catalogApi = useApi(catalogApiRef); + const { namespace, name } = useEntityCompoundName(); + + const { value: entity, error, loading } = useAsync( + () => catalogApi.getEntityByName({ kind: 'Component', namespace, name }), + [catalogApi, namespace, name], + ); + + return { entity, error, loading }; +} diff --git a/plugins/rollbar/src/hooks/useProject.ts b/plugins/rollbar/src/hooks/useProject.ts new file mode 100644 index 0000000000..5d61b63ffb --- /dev/null +++ b/plugins/rollbar/src/hooks/useProject.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi, configApiRef } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { ROLLBAR_ANNOTATION } from '../constants'; + +export function useProjectSlugFromEntity(entity: Entity) { + const configApi = useApi(configApiRef); + + const [project, organization] = ( + entity?.metadata?.annotations?.[ROLLBAR_ANNOTATION] ?? '' + ) + .split('/') + .reverse(); + + return { + project, + organization: + organization ?? + configApi.getOptionalString('rollbar.organization') ?? + configApi.getString('organization.name'), + }; +} diff --git a/plugins/rollbar/src/components/RollbarPage/RollbarPage.tsx b/plugins/rollbar/src/hooks/useRollbarEntities.ts similarity index 55% rename from plugins/rollbar/src/components/RollbarPage/RollbarPage.tsx rename to plugins/rollbar/src/hooks/useRollbarEntities.ts index 0460b4a308..3edf34d1f3 100644 --- a/plugins/rollbar/src/components/RollbarPage/RollbarPage.tsx +++ b/plugins/rollbar/src/hooks/useRollbarEntities.ts @@ -14,29 +14,25 @@ * limitations under the License. */ -import React from 'react'; import { useAsync } from 'react-use'; -import { configApiRef, useApi } from '@backstage/core'; -import { rollbarApiRef } from '../../api/RollbarApi'; -import { RollbarLayout } from '../RollbarLayout/RollbarLayout'; -import { RollbarProjectTable } from './RollbarProjectTable'; +import { useApi, configApiRef } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog'; +import { ROLLBAR_ANNOTATION } from '../constants'; -export const RollbarPage = () => { +export function useRollbarEntities() { const configApi = useApi(configApiRef); - const rollbarApi = useApi(rollbarApiRef); - const org = + const catalogApi = useApi(catalogApiRef); + + const organization = configApi.getOptionalString('rollbar.organization') ?? configApi.getString('organization.name'); - const { value, loading, error } = useAsync(() => rollbarApi.getAllProjects()); - return ( - - - - ); -}; + const { value, loading, error } = useAsync(async () => { + const entities = await catalogApi.getEntities(); + return entities.filter(entity => { + return !!entity.metadata.annotations?.[ROLLBAR_ANNOTATION]; + }); + }, [catalogApi]); + + return { entities: value, organization, loading, error }; +} diff --git a/plugins/rollbar/src/hooks/useTopActiveItems.ts b/plugins/rollbar/src/hooks/useTopActiveItems.ts new file mode 100644 index 0000000000..cb1689355f --- /dev/null +++ b/plugins/rollbar/src/hooks/useTopActiveItems.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useAsync } from 'react-use'; +import { useApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { rollbarApiRef } from '../api'; +import { RollbarTopActiveItem } from '../api/types'; +import { useProjectSlugFromEntity } from './useProject'; + +export function useTopActiveItems(entity: Entity) { + const api = useApi(rollbarApiRef); + const { organization, project } = useProjectSlugFromEntity(entity); + const { value, loading, error } = useAsync(() => { + if (!project) { + return Promise.resolve([]); + } + + return api + .getTopActiveItems(project, 168) + .then(data => + data.sort((a, b) => b.item.occurrences - a.item.occurrences), + ); + }, [api, organization, project, entity]); + + return { + items: value as RollbarTopActiveItem[], + organization, + project, + loading, + error, + }; +} diff --git a/plugins/rollbar/src/index.ts b/plugins/rollbar/src/index.ts index 3a8288a545..2a31c91a13 100644 --- a/plugins/rollbar/src/index.ts +++ b/plugins/rollbar/src/index.ts @@ -15,6 +15,9 @@ */ export { plugin } from './plugin'; -export * from './api/RollbarApi'; -export { RollbarClient } from './api/RollbarClient'; -export { RollbarMockClient } from './api/RollbarMockClient'; +export * from './api'; +export * from './routes'; +export { Router } from './components/Router'; +export { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage'; +export { EntityPageRollbar } from './components/EntityPageRollbar/EntityPageRollbar'; +export { ROLLBAR_ANNOTATION } from './constants'; diff --git a/plugins/rollbar/src/plugin.ts b/plugins/rollbar/src/plugin.ts index d682750a83..c3ca6173ff 100644 --- a/plugins/rollbar/src/plugin.ts +++ b/plugins/rollbar/src/plugin.ts @@ -14,15 +14,28 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; -import { RollbarPage } from './components/RollbarPage/RollbarPage'; +import { + createPlugin, + createApiFactory, + discoveryApiRef, +} from '@backstage/core'; +import { rootRouteRef, entityRouteRef } from './routes'; +import { RollbarHome } from './components/RollbarHome/RollbarHome'; import { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage'; -import { rootRoute, rootProjectRoute } from './routes'; +import { rollbarApiRef } from './api/RollbarApi'; +import { RollbarClient } from './api/RollbarClient'; export const plugin = createPlugin({ id: 'rollbar', + apis: [ + createApiFactory({ + api: rollbarApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new RollbarClient({ discoveryApi }), + }), + ], register({ router }) { - router.addRoute(rootRoute, RollbarPage); - router.addRoute(rootProjectRoute, RollbarProjectPage); + router.addRoute(rootRouteRef, RollbarHome); + router.addRoute(entityRouteRef, RollbarProjectPage); }, }); diff --git a/plugins/rollbar/src/routes.ts b/plugins/rollbar/src/routes.ts index ecf20f3167..b514dff3c8 100644 --- a/plugins/rollbar/src/routes.ts +++ b/plugins/rollbar/src/routes.ts @@ -16,12 +16,21 @@ import { createRouteRef } from '@backstage/core'; -export const rootRoute = createRouteRef({ +const NoIcon = () => null; + +export const rootRouteRef = createRouteRef({ + icon: NoIcon, path: '/rollbar', title: 'Rollbar Home', }); -export const rootProjectRoute = createRouteRef({ - path: '/rollbar/:componentId/*', +export const entityRouteRef = createRouteRef({ + path: '/rollbar/:optionalNamespaceAndName', + title: 'Rollbar', +}); + +export const catalogRouteRef = createRouteRef({ + icon: NoIcon, + path: '', title: 'Rollbar', }); diff --git a/plugins/rollbar/src/utils/index.ts b/plugins/rollbar/src/utils/index.ts new file mode 100644 index 0000000000..1c8f8ff39e --- /dev/null +++ b/plugins/rollbar/src/utils/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const buildProjectUrl = (org: string, id: number) => + `https://rollbar.com/${org}/all/items/?projects=${id}`; + +export const buildItemUrl = (org: string, project: string, id: number) => + `https://rollbar.com/${org}/${project}/items/${id}`; diff --git a/plugins/scaffolder-backend/README.md b/plugins/scaffolder-backend/README.md index 7ffbeae1d1..185938b006 100644 --- a/plugins/scaffolder-backend/README.md +++ b/plugins/scaffolder-backend/README.md @@ -1,4 +1,5 @@ # Title + Welcome to the scaffolder plugin! ## Sub-section 1 diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index fc956c9832..e0a9ae5129 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -17,37 +17,40 @@ "test": "backstage-cli test", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean", - "mock-data": "./scripts/mock-data.sh" + "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.18", - "@backstage/catalog-model": "^0.1.1-alpha.18", - "@backstage/config": "^0.1.1-alpha.18", + "@backstage/backend-common": "^0.1.1-alpha.21", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/config": "^0.1.1-alpha.21", + "@gitbeaker/core": "^23.5.0", + "@gitbeaker/node": "^23.5.0", "@octokit/rest": "^18.0.0", "@types/dockerode": "^2.5.32", "@types/express": "^4.17.6", + "command-exists-promise": "^2.0.2", "compression": "^1.7.4", "cors": "^2.8.5", "dockerode": "^3.2.0", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", - "git-url-parse": "^11.1.2", + "git-url-parse": "^11.2.0", "globby": "^11.0.0", "helmet": "^4.0.0", + "jsonschema": "^1.2.6", "morgan": "^1.10.0", - "nodegit": "0.26.5", + "nodegit": "0.27.0", "uuid": "^8.2.0", "winston": "^3.2.1", "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@octokit/types": "^5.0.1", + "@backstage/cli": "^0.1.1-alpha.21", + "@octokit/types": "^5.4.1", "@types/fs-extra": "^9.0.1", "@types/git-url-parse": "^9.0.0", - "@types/nodegit": "0.26.7", + "@types/nodegit": "0.26.8", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", "yaml": "^1.10.0" diff --git a/plugins/scaffolder-backend/sample-templates/all-templates.yaml b/plugins/scaffolder-backend/sample-templates/all-templates.yaml new file mode 100644 index 0000000000..1eb619a072 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/all-templates.yaml @@ -0,0 +1,13 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-templates + description: A collection of all Backstage example templates +spec: + type: github + targets: + - https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml + - https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml + - https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml + - https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml + - https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml diff --git a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml index 958882e9fd..2c8b3b3d6d 100644 --- a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml @@ -5,9 +5,9 @@ metadata: title: Create React App Template description: Create a new CRA website project tags: - - Experimental - - React - - CRA + - experimental + - react + - cra spec: owner: web@example.com templater: cra @@ -22,11 +22,11 @@ spec: component_id: title: Name type: string - description: Unique name of the component + description: Unique name of the component. Lowercase, URL-safe characters only. description: title: Description type: string - description: Description of the component + description: Help others understand what this website is for. use_typescript: title: Use Typescript type: boolean diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml new file mode 100644 index 0000000000..b4bd45d163 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml @@ -0,0 +1,29 @@ +apiVersion: backstage.io/v1alpha1 +kind: Template +metadata: + name: docs-template + title: Documentation Template + description: Create a new standalone documentation project + tags: + - recommended + - techdocs + - mkdocs +spec: + owner: spotify/techdocs-core + templater: cookiecutter + type: documentation + path: '.' + + schema: + required: + - component_id + properties: + component_id: + title: Name + type: string + description: Unique name of the component + description: + title: Description + type: string + description: Help others understand what these docs are about. + diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml new file mode 100644 index 0000000000..854b692fcf --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml @@ -0,0 +1,12 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: {{cookiecutter.component_id}} + description: {{cookiecutter.description}} + annotations: + github.com/project-slug: {{cookiecutter.storePath}} + backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} +spec: + type: documentation + lifecycle: experimental + owner: {{cookiecutter.owner}} diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/docs/index.md b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/docs/index.md new file mode 100644 index 0000000000..5352ef7801 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/docs/index.md @@ -0,0 +1,28 @@ +## {{ cookiecutter.component_id }} + +{{ cookiecutter.description }} + +## Getting started + +Start write your documentation by adding more markdown (.md) files to this folder (/docs) or replace the content in this file. + +## Table of Contents + +The Table of Contents on the right is generated automatically based on the hierarchy +of headings. Only use one H1 (`#` in Markdown) per file. + +## Site navigation + +For new pages to appear in the left hand navigation you need edit the `mkdocs.yml` +file in root of your repo. The navigation can also link out to other sites. + +Alternatively, if there is no `nav` section in `mkdocs.yml`, a navigation section +will be created for you. However, you will not be able to use alternate titles for +pages, or include links to other sites. + +Note that MkDocs uses `mkdocs.yml`, not `mkdocs.yaml`, although both appear to work. +See also . + +## Support + +That's it. If you need support, reach out in [#docs-like-code](https://discord.com/channels/687207715902193673/714754240933003266) on Discord. diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/mkdocs.yml b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/mkdocs.yml new file mode 100644 index 0000000000..2126971502 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/mkdocs.yml @@ -0,0 +1,8 @@ +site_name: {{cookiecutter.component_id}} +site_description: {{cookiecutter.description}} + +nav: + - Introduction: index.md + +plugins: + - techdocs-core diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml index 5594a3a919..a667e6e6b6 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml @@ -5,8 +5,8 @@ metadata: title: React SSR Template description: Create a website powered with Next.js tags: - - Recommended - - React + - recommended + - react spec: owner: web@example.com templater: cookiecutter @@ -24,4 +24,4 @@ spec: description: title: Description type: string - description: Description of the component + description: Help others understand what this website is for. diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml index 65a77d4a27..f6e1d2c8d9 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml @@ -5,7 +5,7 @@ metadata: description: {{cookiecutter.description}} annotations: github.com/project-slug: {{cookiecutter.storePath}} - backstage.io/github-actions-id: {{cookiecutter.storePath}} + backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} spec: type: website lifecycle: experimental diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/docs/index.md b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/docs/index.md new file mode 100644 index 0000000000..2a0f572ff2 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/docs/index.md @@ -0,0 +1,28 @@ +# {{ cookiecutter.component_id }} + +{{ cookiecutter.description }} + +## Getting started + +Start writing your documentation by adding more markdown (.md) files to this folder (/docs) or replace the content in this file. + +## Table of Contents + +The Table of Contents on the right is generated automatically based on the hierarchy +of headings. Only use one H1 (`#` in Markdown) per file. + +## Site navigation + +For new pages to appear in the left hand navigation you need edit the `mkdocs.yml` +file in root of your repo. The navigation can also link out to other sites. + +Alternatively, if there is no `nav` section in `mkdocs.yml`, a navigation section +will be created for you. However, you will not be able to use alternate titles for +pages, or include links to other sites. + +Note that MkDocs uses `mkdocs.yml`, not `mkdocs.yaml`, although both appear to work. +See also . + +## Support + +That's it. If you need support, reach out in [#docs-like-code](https://discord.com/channels/687207715902193673/714754240933003266) on Discord. diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/mkdocs.yml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/mkdocs.yml new file mode 100644 index 0000000000..0d10d11063 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/mkdocs.yml @@ -0,0 +1,8 @@ +site_name: {{cookiecutter.component_id}} +site_description: {{cookiecutter.description}} + +nav: + - Introduction: index.md + +plugins: + - techdocs-core diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml index 520d0a62ac..8b89298a0e 100644 --- a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml @@ -2,11 +2,12 @@ apiVersion: backstage.io/v1alpha1 kind: Template metadata: name: springboot-template - title: Spring Boot GRPC Service + title: Spring Boot gRPC Service description: Create a simple microservice using gRPC and Spring Boot Java tags: - - Recommended - - Java + - recommended + - java + - grpc spec: owner: service@example.com templater: cookiecutter @@ -24,9 +25,9 @@ spec: description: title: Description type: string - description: Description of the component + description: Help others understand what this service does. http_port: title: Port type: integer default: 8080 - description: The port to run the GRPC service on + description: The port to run the gRPC service on diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml index 15c8fdcfff..05b87c9c0c 100644 --- a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml @@ -5,7 +5,7 @@ metadata: description: {{cookiecutter.description}} annotations: github.com/project-slug: {{cookiecutter.storePath}} - backstage.io/github-actions-id: {{cookiecutter.storePath}} + backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} spec: type: service lifecycle: experimental diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/docs/index.md b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/docs/index.md new file mode 100644 index 0000000000..5352ef7801 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/docs/index.md @@ -0,0 +1,28 @@ +## {{ cookiecutter.component_id }} + +{{ cookiecutter.description }} + +## Getting started + +Start write your documentation by adding more markdown (.md) files to this folder (/docs) or replace the content in this file. + +## Table of Contents + +The Table of Contents on the right is generated automatically based on the hierarchy +of headings. Only use one H1 (`#` in Markdown) per file. + +## Site navigation + +For new pages to appear in the left hand navigation you need edit the `mkdocs.yml` +file in root of your repo. The navigation can also link out to other sites. + +Alternatively, if there is no `nav` section in `mkdocs.yml`, a navigation section +will be created for you. However, you will not be able to use alternate titles for +pages, or include links to other sites. + +Note that MkDocs uses `mkdocs.yml`, not `mkdocs.yaml`, although both appear to work. +See also . + +## Support + +That's it. If you need support, reach out in [#docs-like-code](https://discord.com/channels/687207715902193673/714754240933003266) on Discord. diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/mkdocs.yml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/mkdocs.yml new file mode 100644 index 0000000000..0d10d11063 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/mkdocs.yml @@ -0,0 +1,8 @@ +site_name: {{cookiecutter.component_id}} +site_description: {{cookiecutter.description}} + +nav: + - Introduction: index.md + +plugins: + - techdocs-core diff --git a/plugins/scaffolder-backend/scripts/mock-data.sh b/plugins/scaffolder-backend/scripts/mock-data.sh deleted file mode 100755 index f18d4b4838..0000000000 --- a/plugins/scaffolder-backend/scripts/mock-data.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash - -for URL in \ - 'react-ssr-template' \ - 'springboot-grpc-template' \ - 'create-react-app' \ -; do \ - curl \ - --location \ - --request POST 'localhost:7000/catalog/locations' \ - --header 'Content-Type: application/json' \ - --data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/sample-templates/${URL}/template.yaml\"}" - echo -done - -curl \ - --location \ - --request POST 'localhost:7000/catalog/locations' \ - --header 'Content-Type: application/json' \ - --data-raw "{\"type\": \"github\", \"target\": \"https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml\"}" -echo diff --git a/plugins/scaffolder-backend/src/index.ts b/plugins/scaffolder-backend/src/index.ts index 0a0a4cb95f..c461bfede6 100644 --- a/plugins/scaffolder-backend/src/index.ts +++ b/plugins/scaffolder-backend/src/index.ts @@ -16,4 +16,3 @@ export * from './scaffolder'; export * from './service/router'; - diff --git a/plugins/scaffolder-backend/src/scaffolder/__mocks__/@gitbeaker/node/index.ts b/plugins/scaffolder-backend/src/scaffolder/__mocks__/@gitbeaker/node/index.ts new file mode 100644 index 0000000000..abf6a25be6 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/__mocks__/@gitbeaker/node/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const mockGitlabClient = { + Namespaces: { + show: jest.fn(), + }, + Projects: { + create: jest.fn(), + }, + Users: { + current: jest.fn(), + }, +}; + +export class Gitlab { + constructor() { + return mockGitlabClient; + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/__mocks__/@octokit/rest/index.ts b/plugins/scaffolder-backend/src/scaffolder/__mocks__/@octokit/rest/index.ts index 85bed3a223..e0e9efa479 100644 --- a/plugins/scaffolder-backend/src/scaffolder/__mocks__/@octokit/rest/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/__mocks__/@octokit/rest/index.ts @@ -18,6 +18,13 @@ export const mockGithubClient = { repos: { createInOrg: jest.fn(), createForAuthenticatedUser: jest.fn(), + addCollaborator: jest.fn(), + }, + users: { + getByUsername: jest.fn(), + }, + teams: { + addOrUpdateRepoPermissionsInOrg: jest.fn(), }, }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/index.ts index 78ea30db79..dfe49a8aa5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/index.ts @@ -16,3 +16,4 @@ export * from './prepare'; export * from './publish'; export * from './templater'; +export * from './helpers'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts index 7a8361d6ea..b5ac846cf2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts @@ -17,7 +17,7 @@ import fs from 'fs-extra'; import path from 'path'; import os from 'os'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from './helpers'; +import { parseLocationAnnotation } from '../helpers'; import { InputError } from '@backstage/backend-common'; import { PreparerBase } from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index a61e5de867..4409d815b5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -96,6 +96,8 @@ describe('GitHubPreparer', () => { mockEntity.spec.path = './template/test/1/2/3'; const response = await preparer.prepare(mockEntity); - expect(response).toMatch(new RegExp(/\/template\/test\/1\/2\/3$/)); + expect(response.split('\\').join('/')).toMatch( + /\/template\/test\/1\/2\/3$/, + ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index f53151e55d..ba4adcabd9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -17,7 +17,7 @@ import fs from 'fs-extra'; import path from 'path'; import os from 'os'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from './helpers'; +import { parseLocationAnnotation } from '../helpers'; import { InputError } from '@backstage/backend-common'; import { PreparerBase } from './types'; import GitUriParser from 'git-url-parse'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts new file mode 100644 index 0000000000..b25886b027 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const mocks = { + Clone: { clone: jest.fn() }, + CheckoutOptions: jest.fn(() => {}), +}; +jest.doMock('nodegit', () => mocks); + +import { GitlabPreparer } from './gitlab'; +import { + TemplateEntityV1alpha1, + LOCATION_ANNOTATION, +} from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; + +const mockEntityWithProtocol = (protocol: string): TemplateEntityV1alpha1 => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + annotations: { + [LOCATION_ANNOTATION]: `${protocol}:https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/template.yaml`, + }, + name: 'graphql-starter', + title: 'GraphQL Service', + description: + 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', + uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', + etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', + generation: 1, + }, + spec: { + type: 'website', + templater: 'cookiecutter', + path: './template', + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + required: ['storePath', 'owner'], + properties: { + owner: { + type: 'string', + title: 'Owner', + description: 'Who is going to own this component', + }, + storePath: { + type: 'string', + title: 'Store path', + description: 'GitHub store path in org/repo format', + }, + }, + }, + }, +}); + +describe('GitLabPreparer', () => { + let mockEntity: TemplateEntityV1alpha1; + beforeEach(() => { + jest.clearAllMocks(); + }); + + ['gitlab', 'gitlab/api'].forEach(protocol => { + it(`calls the clone command with the correct arguments for a repository using the ${protocol} protocol`, async () => { + const preparer = new GitlabPreparer(ConfigReader.fromConfigs([])); + mockEntity = mockEntityWithProtocol(protocol); + await preparer.prepare(mockEntity); + expect(mocks.Clone.clone).toHaveBeenNthCalledWith( + 1, + 'https://gitlab.com/benjdlambert/backstage-graphql-template', + expect.any(String), + {}, + ); + }); + + it(`calls the clone command with the correct arguments if an access token is provided for a repository using the ${protocol} protocol`, async () => { + const preparer = new GitlabPreparer( + ConfigReader.fromConfigs([ + { + context: '', + data: { + catalog: { + processors: { + gitlabApi: { + privateToken: 'fake-token', + }, + }, + }, + }, + }, + ]), + ); + mockEntity = mockEntityWithProtocol(protocol); + await preparer.prepare(mockEntity); + expect(mocks.Clone.clone).toHaveBeenNthCalledWith( + 1, + 'https://gitlab.com/benjdlambert/backstage-graphql-template', + expect.any(String), + { + fetchOpts: { + callbacks: { + credentials: expect.anything(), + }, + }, + }, + ); + }); + + it(`calls the clone command with the correct arguments for a repository when no path is provided using the ${protocol} protocol`, async () => { + const preparer = new GitlabPreparer(ConfigReader.fromConfigs([])); + mockEntity = mockEntityWithProtocol(protocol); + delete mockEntity.spec.path; + await preparer.prepare(mockEntity); + expect(mocks.Clone.clone).toHaveBeenNthCalledWith( + 1, + 'https://gitlab.com/benjdlambert/backstage-graphql-template', + expect.any(String), + {}, + ); + }); + + it(`return the temp directory with the path to the folder if it is specified using the ${protocol} protocol`, async () => { + const preparer = new GitlabPreparer(ConfigReader.fromConfigs([])); + mockEntity = mockEntityWithProtocol(protocol); + mockEntity.spec.path = './template/test/1/2/3'; + const response = await preparer.prepare(mockEntity); + + expect(response.split('\\').join('/')).toMatch( + /\/template\/test\/1\/2\/3$/, + ); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts new file mode 100644 index 0000000000..8efc158a96 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { parseLocationAnnotation } from '../helpers'; +import { InputError } from '@backstage/backend-common'; +import { PreparerBase } from './types'; +import GitUriParser from 'git-url-parse'; +import { Clone, Cred } from 'nodegit'; +import { Config } from '@backstage/config'; + +export class GitlabPreparer implements PreparerBase { + private readonly privateToken: string; + + constructor(config: Config) { + this.privateToken = + config.getOptionalString('catalog.processors.gitlabApi.privateToken') ?? + ''; + } + + async prepare(template: TemplateEntityV1alpha1): Promise { + const { protocol, location } = parseLocationAnnotation(template); + + if (['gitlab', 'gitlab/api'].indexOf(protocol) < 0) { + throw new InputError( + `Wrong location protocol: ${protocol}, should be 'gitlab' or 'gitlab/api'`, + ); + } + const templateId = template.metadata.name; + + const parsedGitLocation = GitUriParser(location); + const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), templateId), + ); + + const templateDirectory = path.join( + `${path.dirname(parsedGitLocation.filepath)}`, + template.spec.path ?? '.', + ); + + const options = this.privateToken + ? { + fetchOpts: { + callbacks: { + credentials: () => + Cred.userpassPlaintextNew('oauth2', this.privateToken), + }, + }, + } + : {}; + + await Clone.clone(repositoryCheckoutUrl, tempDir, options); + + return path.resolve(tempDir, templateDirectory); + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts index 4ae4f68164..80a7dc620d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts @@ -15,6 +15,6 @@ */ export * from './preparers'; export * from './types'; -export * from './helpers'; export * from './file'; export * from './github'; +export * from './gitlab'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts index 046890bdca..5efb3ad60d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts @@ -14,9 +14,10 @@ * limitations under the License. */ -import { PreparerBase, RemoteProtocol, PreparerBuilder } from './types'; +import { PreparerBase, PreparerBuilder } from './types'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from './helpers'; +import { parseLocationAnnotation } from '../helpers'; +import { RemoteProtocol } from '../types'; export class Preparers implements PreparerBuilder { private preparerMap = new Map(); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts index adbc38ef58..c9dd14c0a3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts @@ -15,6 +15,7 @@ */ import type { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Logger } from 'winston'; +import { RemoteProtocol } from '../types'; export type PreparerBase = { /** @@ -32,5 +33,3 @@ export type PreparerBuilder = { register(protocol: RemoteProtocol, preparer: PreparerBase): void; get(template: TemplateEntityV1alpha1): PreparerBase; }; - -export type RemoteProtocol = 'file' | 'github'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts index 164ec316df..c06a553771 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts @@ -19,11 +19,19 @@ jest.mock('nodegit'); import { Octokit } from '@octokit/rest'; import * as NodeGit from 'nodegit'; -import { OctokitResponse, ReposCreateInOrgResponseData } from '@octokit/types'; +import { + OctokitResponse, + ReposCreateInOrgResponseData, + UsersGetByUsernameResponseData, +} from '@octokit/types'; import { GithubPublisher } from './github'; const { mockGithubClient } = require('@octokit/rest') as { - mockGithubClient: { repos: jest.Mocked }; + mockGithubClient: { + repos: jest.Mocked; + users: jest.Mocked; + teams: jest.Mocked; + }; }; const { @@ -45,20 +53,244 @@ const { mockRemote: jest.Mocked; }; -describe('Github Publisher', () => { - const publisher = new GithubPublisher({ client: new Octokit() }); - +describe('GitHub Publisher', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe('publish: createRemoteInGithub', () => { - it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { + describe('with public repo visibility', () => { + const publisher = new GithubPublisher({ + client: new Octokit(), + token: 'abc', + repoVisibility: 'public', + }); + + describe('publish: createRemoteInGithub', () => { + it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { + mockGithubClient.repos.createInOrg.mockResolvedValue({ + data: { + clone_url: 'mockclone', + }, + } as OctokitResponse); + + await publisher.publish({ + values: { + storePath: 'blam/test', + owner: 'bob', + access: 'blam/team', + }, + directory: '/tmp/test', + }); + + expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ + org: 'blam', + name: 'test', + private: false, + visibility: 'public', + }); + expect( + mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg, + ).toHaveBeenCalledWith({ + org: 'blam', + team_slug: 'team', + owner: 'blam', + repo: 'test', + permission: 'admin', + }); + }); + + it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => { + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'mockclone', + }, + } as OctokitResponse); + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { + type: 'User', + }, + } as OctokitResponse); + + await publisher.publish({ + values: { + storePath: 'blam/test', + owner: 'bob', + access: 'blam', + }, + directory: '/tmp/test', + }); + + expect( + mockGithubClient.repos.createForAuthenticatedUser, + ).toHaveBeenCalledWith({ + name: 'test', + private: false, + }); + expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); + }); + }); + + it('should invite other user in the authed user', async () => { + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'mockclone', + }, + } as OctokitResponse); + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { + type: 'User', + }, + } as OctokitResponse); + + await publisher.publish({ + values: { + storePath: 'blam/test', + owner: 'bob', + access: 'bob', + }, + directory: '/tmp/test', + }); + + expect( + mockGithubClient.repos.createForAuthenticatedUser, + ).toHaveBeenCalledWith({ + name: 'test', + private: false, + }); + expect(mockGithubClient.repos.addCollaborator).toHaveBeenCalledWith({ + owner: 'blam', + repo: 'test', + username: 'bob', + permission: 'admin', + }); + }); + + describe('publish: createGitDirectory', () => { + const values = { + storePath: 'blam/test', + owner: 'lols', + access: 'lols', + }; + + const mockDir = '/tmp/test/dir'; + mockGithubClient.repos.createInOrg.mockResolvedValue({ data: { clone_url: 'mockclone', }, } as OctokitResponse); + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { + type: 'Organization', + }, + } as OctokitResponse); + + it('should call init on the repo with the directory', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); + }); + + it('should call refresh index on the index and write the new files', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(mockRepo.refreshIndex).toHaveBeenCalled(); + }); + + it('should call add all files and write', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(mockIndex.addAll).toHaveBeenCalled(); + expect(mockIndex.write).toHaveBeenCalled(); + expect(mockIndex.writeTree).toHaveBeenCalled(); + }); + + it('should create a commit with on head with the right name and commiter', async () => { + const mockSignature = { mockSignature: 'bloblly' }; + Signature.now.mockReturnValue(mockSignature); + + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Signature.now).toHaveBeenCalledTimes(2); + expect(Signature.now).toHaveBeenCalledWith( + 'Scaffolder', + 'scaffolder@backstage.io', + ); + + expect(mockRepo.createCommit).toHaveBeenCalledWith( + 'HEAD', + mockSignature, + mockSignature, + 'initial commit', + 'mockoid', + [], + ); + }); + + it('creates a remote with the repo and remote', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Remote.create).toHaveBeenCalledWith( + mockRepo, + 'origin', + 'mockclone', + ); + }); + + it('shoud push to the remote repo', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + const [remotes, { callbacks }] = mockRemote.push.mock + .calls[0] as NodeGit.PushOptions[]; + + expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); + + callbacks?.credentials?.(); + + expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( + 'abc', + 'x-oauth-basic', + ); + }); + }); + }); + + describe('with internal repo visibility', () => { + const publisher = new GithubPublisher({ + client: new Octokit(), + token: 'abc', + repoVisibility: 'internal', + }); + + it('creates a private repository in the organization with visibility set to internal', async () => { + mockGithubClient.repos.createInOrg.mockResolvedValue({ + data: { + clone_url: 'mockclone', + }, + } as OctokitResponse); + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { + type: 'Organization', + }, + } as OctokitResponse); await publisher.publish({ values: { @@ -72,15 +304,30 @@ describe('Github Publisher', () => { expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ org: 'blam', name: 'test', + private: true, + visibility: 'internal', }); }); + }); - it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => { + describe('private visibility in a user account', () => { + const publisher = new GithubPublisher({ + client: new Octokit(), + token: 'abc', + repoVisibility: 'private', + }); + + it('creates a private repository', async () => { mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { clone_url: 'mockclone', }, } as OctokitResponse); + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { + type: 'User', + }, + } as OctokitResponse); await publisher.publish({ values: { @@ -94,110 +341,8 @@ describe('Github Publisher', () => { mockGithubClient.repos.createForAuthenticatedUser, ).toHaveBeenCalledWith({ name: 'test', + private: true, }); }); }); - - describe('publish: createGitDirectory', () => { - const values = { - isOrg: true, - storePath: 'blam/test', - owner: 'lols', - }; - - const mockDir = '/tmp/test/dir'; - - mockGithubClient.repos.createInOrg.mockResolvedValue({ - data: { - clone_url: 'mockclone', - }, - } as OctokitResponse); - it('should call init on the repo with the directory', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); - }); - - it('should call refresh index on the index and write the new files', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockRepo.refreshIndex).toHaveBeenCalled(); - }); - - it('should call add all files and write', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockIndex.addAll).toHaveBeenCalled(); - expect(mockIndex.write).toHaveBeenCalled(); - expect(mockIndex.writeTree).toHaveBeenCalled(); - }); - - it('should create a commit with on head with the right name and commiter', async () => { - const mockSignature = { mockSignature: 'bloblly' }; - Signature.now.mockReturnValue(mockSignature); - - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Signature.now).toHaveBeenCalledTimes(2); - expect(Signature.now).toHaveBeenCalledWith( - 'Scaffolder', - 'scaffolder@backstage.io', - ); - - expect(mockRepo.createCommit).toHaveBeenCalledWith( - 'HEAD', - mockSignature, - mockSignature, - 'initial commit', - 'mockoid', - [], - ); - }); - - it('creates a remote with the repo and remote', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Remote.create).toHaveBeenCalledWith( - mockRepo, - 'origin', - 'mockclone', - ); - }); - - it('shoud push to the remote repo', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - const [remotes, { callbacks }] = mockRemote.push.mock - .calls[0] as NodeGit.PushOptions[]; - - expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); - - process.env.GITHUb_ACCESS_TOKEN = 'blob'; - - callbacks?.credentials?.(); - - expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( - process.env.GITHUB_ACCESS_TOKEN, - 'x-oauth-basic', - ); - }); - }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 5fd64d69fc..1d5c9e1947 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -21,10 +21,27 @@ import { JsonValue } from '@backstage/config'; import { RequiredTemplateValues } from '../templater'; import { Repository, Remote, Signature, Cred } from 'nodegit'; +export type RepoVisilityOptions = 'private' | 'internal' | 'public'; + +interface GithubPublisherParams { + client: Octokit; + token: string; + repoVisibility: RepoVisilityOptions; +} + export class GithubPublisher implements PublisherBase { private client: Octokit; - constructor({ client }: { client: Octokit }) { + private token: string; + private repoVisibility: RepoVisilityOptions; + + constructor({ + client, + token, + repoVisibility = 'public', + }: GithubPublisherParams) { this.client = client; + this.token = token; + this.repoVisibility = repoVisibility; } async publish({ @@ -45,12 +62,43 @@ export class GithubPublisher implements PublisherBase { ) { const [owner, name] = values.storePath.split('/'); - const repoCreationPromise = values.isOrg - ? this.client.repos.createInOrg({ name, org: owner }) - : this.client.repos.createForAuthenticatedUser({ name }); + const user = await this.client.users.getByUsername({ username: owner }); + + const repoCreationPromise = + user.data.type === 'Organization' + ? this.client.repos.createInOrg({ + name, + org: owner, + private: this.repoVisibility !== 'public', + visibility: this.repoVisibility, + }) + : this.client.repos.createForAuthenticatedUser({ + name, + private: this.repoVisibility === 'private', + }); const { data } = await repoCreationPromise; + const access = values.access as string; + if (access?.startsWith(`${owner}/`)) { + const [, team] = access.split('/'); + await this.client.teams.addOrUpdateRepoPermissionsInOrg({ + org: owner, + team_slug: team, + owner, + repo: name, + permission: 'admin', + }); + // no need to add access if it's the person who own's the personal account + } else if (access && access !== owner) { + await this.client.repos.addCollaborator({ + owner, + repo: name, + username: access, + permission: 'admin', + }); + } + return data?.clone_url; } @@ -73,10 +121,7 @@ export class GithubPublisher implements PublisherBase { await remoteRepo.push(['refs/heads/master:refs/heads/master'], { callbacks: { credentials: () => { - return Cred.userpassPlaintextNew( - process.env.GITHUB_ACCESS_TOKEN as string, - 'x-oauth-basic', - ); + return Cred.userpassPlaintextNew(this.token, 'x-oauth-basic'); }, }, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts new file mode 100644 index 0000000000..43b19859de --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -0,0 +1,205 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('nodegit'); +jest.mock('@gitbeaker/node'); + +import { GitlabPublisher } from './gitlab'; +import { Gitlab as GitlabAPI } from '@gitbeaker/core'; +import { Gitlab } from '@gitbeaker/node'; +import * as NodeGit from 'nodegit'; + +const { mockGitlabClient } = require('@gitbeaker/node') as { + mockGitlabClient: { + Namespaces: jest.Mocked; + Projects: jest.Mocked; + Users: jest.Mocked; + }; +}; + +const { + Repository, + mockRepo, + mockIndex, + Signature, + Remote, + mockRemote, + Cred, +} = require('nodegit') as { + Repository: jest.Mocked<{ init: any }>; + Signature: jest.Mocked<{ now: any }>; + Cred: jest.Mocked<{ userpassPlaintextNew: any }>; + Remote: jest.Mocked<{ create: any }>; + + mockIndex: jest.Mocked; + mockRepo: jest.Mocked; + mockRemote: jest.Mocked; +}; + +describe('GitLab Publisher', () => { + const publisher = new GitlabPublisher(new Gitlab({}), 'fake-token'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('publish: createRemoteInGitLab', () => { + it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => { + mockGitlabClient.Namespaces.show.mockResolvedValue({ + id: 42, + } as { id: number }); + + await publisher.publish({ + values: { + isOrg: true, + storePath: 'blam/test', + owner: 'bob', + }, + directory: '/tmp/test', + }); + + expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ + namespace_id: 42, + name: 'test', + }); + }); + + it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => { + mockGitlabClient.Namespaces.show.mockResolvedValue({}); + mockGitlabClient.Users.current.mockResolvedValue({ + id: 21, + } as { id: number }); + mockGitlabClient.Projects.create.mockResolvedValue({ + http_url_to_repo: 'mockclone', + } as { http_url_to_repo: string }); + + await publisher.publish({ + values: { + storePath: 'blam/test', + owner: 'bob', + }, + directory: '/tmp/test', + }); + + expect(mockGitlabClient.Users.current).toHaveBeenCalled(); + + expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ + namespace_id: 21, + name: 'test', + }); + }); + }); + + describe('publish: createGitDirectory', () => { + const values = { + isOrg: true, + storePath: 'blam/test', + owner: 'lols', + }; + + const mockDir = '/tmp/test/dir'; + + mockGitlabClient.Projects.create.mockResolvedValue({ + http_url_to_repo: 'mockclone', + } as { http_url_to_repo: string }); + + it('should call init on the repo with the directory', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); + }); + + it('should call refresh index on the index and write the new files', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(mockRepo.refreshIndex).toHaveBeenCalled(); + }); + + it('should call add all files and write', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(mockIndex.addAll).toHaveBeenCalled(); + expect(mockIndex.write).toHaveBeenCalled(); + expect(mockIndex.writeTree).toHaveBeenCalled(); + }); + + it('should create a commit with on head with the right name and commiter', async () => { + const mockSignature = { mockSignature: 'bloblly' }; + Signature.now.mockReturnValue(mockSignature); + + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Signature.now).toHaveBeenCalledTimes(2); + expect(Signature.now).toHaveBeenCalledWith( + 'Scaffolder', + 'scaffolder@backstage.io', + ); + + expect(mockRepo.createCommit).toHaveBeenCalledWith( + 'HEAD', + mockSignature, + mockSignature, + 'initial commit', + 'mockoid', + [], + ); + }); + + it('creates a remote with the repo and remote', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + expect(Remote.create).toHaveBeenCalledWith( + mockRepo, + 'origin', + 'mockclone', + ); + }); + + it('shoud push to the remote repo', async () => { + await publisher.publish({ + values, + directory: mockDir, + }); + + const [remotes, { callbacks }] = mockRemote.push.mock + .calls[0] as NodeGit.PushOptions[]; + + expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); + + callbacks?.credentials?.(); + + expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( + 'oauth2', + 'fake-token', + ); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts new file mode 100644 index 0000000000..a755a138b5 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PublisherBase } from './types'; +import { Gitlab } from '@gitbeaker/core'; + +import { JsonValue } from '@backstage/config'; +import { RequiredTemplateValues } from '../templater'; +import { Repository, Remote, Signature, Cred } from 'nodegit'; + +export class GitlabPublisher implements PublisherBase { + private readonly client: Gitlab; + private readonly token: string; + + constructor(client: Gitlab, token: string) { + this.client = client; + this.token = token; + } + + async publish({ + values, + directory, + }: { + values: RequiredTemplateValues & Record; + directory: string; + }): Promise<{ remoteUrl: string }> { + const remoteUrl = await this.createRemote(values); + await this.pushToRemote(directory, remoteUrl); + + return { remoteUrl }; + } + + private async createRemote( + values: RequiredTemplateValues & Record, + ) { + const [owner, name] = values.storePath.split('/'); + + let targetNamespace = ((await this.client.Namespaces.show(owner)) as { + id: number; + }).id; + if (!targetNamespace) { + targetNamespace = ((await this.client.Users.current()) as { id: number }) + .id; + } + + const project = (await this.client.Projects.create({ + namespace_id: targetNamespace, + name: name, + })) as { http_url_to_repo: string }; + + return project?.http_url_to_repo; + } + + private async pushToRemote(directory: string, remote: string): Promise { + const repo = await Repository.init(directory, 0); + const index = await repo.refreshIndex(); + await index.addAll(); + await index.write(); + const oid = await index.writeTree(); + await repo.createCommit( + 'HEAD', + Signature.now('Scaffolder', 'scaffolder@backstage.io'), + Signature.now('Scaffolder', 'scaffolder@backstage.io'), + 'initial commit', + oid, + [], + ); + + const remoteRepo = await Remote.create(repo, 'origin', remote); + + await remoteRepo.push(['refs/heads/master:refs/heads/master'], { + callbacks: { + credentials: () => Cred.userpassPlaintextNew('oauth2', this.token), + }, + }); + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts index 38a96480c2..1d89cd37f0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts @@ -13,5 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +export * from './publishers'; export * from './github'; +export * from './gitlab'; export * from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts new file mode 100644 index 0000000000..b8181b0134 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Publishers } from './publishers'; +import { + LOCATION_ANNOTATION, + TemplateEntityV1alpha1, +} from '@backstage/catalog-model'; +import { GithubPublisher } from './github'; +import { Octokit } from '@octokit/rest'; + +jest.mock('@octokit/rest'); + +describe('Publishers', () => { + const mockTemplate: TemplateEntityV1alpha1 = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + annotations: { + [LOCATION_ANNOTATION]: + 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', + }, + name: 'graphql-starter', + title: 'GraphQL Service', + description: + 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', + uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', + etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', + generation: 1, + }, + spec: { + type: 'website', + templater: 'cookiecutter', + path: './template', + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + required: ['storePath', 'owner'], + properties: { + owner: { + type: 'string', + title: 'Owner', + description: 'Who is going to own this component', + }, + storePath: { + type: 'string', + title: 'Store path', + description: 'GitHub store path in org/repo format', + }, + }, + }, + }, + }; + + it('should throw an error when the publisher for the source location is not registered', () => { + const publishers = new Publishers(); + + expect(() => publishers.get(mockTemplate)).toThrow( + expect.objectContaining({ + message: 'No publisher registered for type: "github"', + }), + ); + }); + + it('should return the correct preparer when the source matches', () => { + const publishers = new Publishers(); + const publisher = new GithubPublisher({ + client: new Octokit(), + token: 'fake', + repoVisibility: 'public', + }); + publishers.register('github', publisher); + + expect(publishers.get(mockTemplate)).toBe(publisher); + }); + + it('should throw an error if the metadata tag does not exist in the entity', () => { + const brokenTemplate: TemplateEntityV1alpha1 = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + annotations: {}, + name: 'react-ssr-template', + title: 'React SSR Template', + description: + 'Next.js application skeleton for creating isomorphic web applications.', + uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', + etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', + generation: 1, + }, + spec: { + type: 'website', + templater: 'cookiecutter', + path: '.', + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + required: ['storePath', 'owner'], + properties: { + owner: { + type: 'string', + title: 'Owner', + description: 'Who is going to own this component', + }, + storePath: { + type: 'string', + title: 'Store path', + description: 'GitHub store path in org/repo format', + }, + }, + }, + }, + }; + + const publishers = new Publishers(); + + expect(() => publishers.get(brokenTemplate)).toThrow( + expect.objectContaining({ + message: expect.stringContaining('No location annotation provided'), + }), + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts new file mode 100644 index 0000000000..a88acc518d --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { parseLocationAnnotation } from '../helpers'; +import { PublisherBase, PublisherBuilder } from './types'; +import { RemoteProtocol } from '../types'; + +export class Publishers implements PublisherBuilder { + private publisherMap = new Map(); + + register(protocol: RemoteProtocol, publisher: PublisherBase) { + this.publisherMap.set(protocol, publisher); + } + + get(template: TemplateEntityV1alpha1): PublisherBase { + const { protocol } = parseLocationAnnotation(template); + const publisher = this.publisherMap.get(protocol); + + if (!publisher) { + throw new Error(`No publisher registered for type: "${protocol}"`); + } + + return publisher; + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts index 5d4e9dd521..6e9c45ba43 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts @@ -16,6 +16,7 @@ import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { RequiredTemplateValues } from '../templater'; import { JsonValue } from '@backstage/config'; +import { RemoteProtocol } from '../types'; /** * Publisher is in charge of taking a folder created by @@ -34,3 +35,8 @@ export type PublisherBase = { directory: string; }): Promise<{ remoteUrl: string }>; }; + +export type PublisherBuilder = { + register(protocol: RemoteProtocol, publisher: PublisherBase): void; + get(template: TemplateEntityV1alpha1): PublisherBase; +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts index fff349cf82..21b6fc3dde 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts @@ -15,11 +15,13 @@ */ import fs from 'fs-extra'; import { JsonValue } from '@backstage/config'; -import { runDockerContainer } from './helpers'; +import { runDockerContainer, runCommand } from './helpers'; import { TemplaterBase, TemplaterRunOptions } from '.'; import path from 'path'; import { TemplaterRunResult } from './types'; +const commandExists = require('command-exists-promise'); + export class CookieCutter implements TemplaterBase { private async fetchTemplateCookieCutter( directory: string, @@ -51,21 +53,30 @@ export class CookieCutter implements TemplaterBase { const templateDir = options.directory; const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`); - await runDockerContainer({ - imageName: 'spotify/backstage-cookiecutter', - args: [ - 'cookiecutter', - '--no-input', - '-o', - '/result', - '/template', - '--verbose', - ], - templateDir, - resultDir, - logStream: options.logStream, - dockerClient: options.dockerClient, - }); + const cookieCutterInstalled = await commandExists('cookiecutter'); + if (cookieCutterInstalled) { + await runCommand({ + command: 'cookiecutter', + args: ['--no-input', '-o', resultDir, templateDir, '--verbose'], + logStream: options.logStream, + }); + } else { + await runDockerContainer({ + imageName: 'spotify/backstage-cookiecutter', + args: [ + 'cookiecutter', + '--no-input', + '-o', + '/result', + '/template', + '--verbose', + ], + templateDir, + resultDir, + logStream: options.logStream, + dockerClient: options.dockerClient, + }); + } return { resultDir: path.resolve(resultDir, options.values.component_id as string), diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts index b7e969cd47..a4441ca23f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts @@ -20,6 +20,13 @@ import Docker from 'dockerode'; import { runDockerContainer } from './helpers'; describe('helpers', () => { + if (process.platform === 'win32') { + // eslint-disable-next-line jest/no-focused-tests + it.only('should skip tests on windows', () => { + expect('test').not.toBe('run'); + }); + } + const mockDocker = new Docker() as jest.Mocked; beforeEach(() => { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts index a3dea7ea5a..e71b63bfb0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts @@ -18,6 +18,7 @@ import Docker from 'dockerode'; import fs from 'fs'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { InputError } from '@backstage/backend-common'; +import { spawn } from 'child_process'; export type RunDockerContainerOptions = { imageName: string; @@ -29,6 +30,12 @@ export type RunDockerContainerOptions = { createOptions?: Docker.ContainerCreateOptions; }; +export type RunCommandOptions = { + command: string; + args: string[]; + logStream?: Writable; +}; + /** * Gets the templater key to use for templating from the entity * @param entity Template entity @@ -43,6 +50,42 @@ export const getTemplaterKey = (entity: TemplateEntityV1alpha1): string => { return templater; }; +/** + * + * @param options the options object + * @param options.command the command to run + * @param options.args the arguments to pass the command + * @param options.logStream the log streamer to capture log messages + */ +export const runCommand = async ({ + command, + args, + logStream = new PassThrough(), +}: RunCommandOptions) => { + await new Promise((resolve, reject) => { + const process = spawn(command, args); + + process.stdout.on('data', stream => { + logStream.write(stream); + }); + + process.stderr.on('data', stream => { + logStream.write(stream); + }); + + process.on('error', error => { + return reject(error); + }); + + process.on('close', code => { + if (code !== 0) { + return reject(`Command ${command} failed, exit code: ${code}`); + } + return resolve(); + }); + }); +}; + /** * * @param options the options object diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts index 6a0fe60858..5b01960612 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts @@ -36,7 +36,7 @@ export type TemplaterRunResult = { }; /** - * The values that the templater will recieve. The directory of the + * The values that the templater will receive. The directory of the * skeleton, with the values from the frontend. A dedicated log stream and a docker * client to run any templater on top of your directory. */ diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/types.ts new file mode 100644 index 0000000000..13817ba190 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export type RemoteProtocol = 'file' | 'github' | 'gitlab' | 'gitlab/api'; diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts new file mode 100644 index 0000000000..35d6b50ece --- /dev/null +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; +import { createRouter } from './router'; +import { Templaters, Preparers, Publishers } from '../scaffolder'; +import Docker from 'dockerode'; + +jest.mock('dockerode'); + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + preparers: new Preparers(), + templaters: new Templaters(), + publishers: new Publishers(), + dockerClient: new Docker(), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('POST /v1/jobs', () => { + const template = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + description: 'Create a new CRA website project', + name: 'create-react-app-template', + tags: ['experimental', 'react', 'cra'], + title: 'Create React App Template', + }, + spec: { + owner: 'web@example.com', + path: '.', + schema: { + properties: { + component_id: { + description: 'Unique name of the component', + title: 'Name', + type: 'string', + }, + description: { + description: 'Description of the component', + title: 'Description', + type: 'string', + }, + use_typescript: { + default: true, + description: 'Include typescript', + title: 'Use Typescript', + type: 'boolean', + }, + }, + required: ['component_id', 'use_typescript'], + }, + templater: 'cra', + type: 'website', + }, + }; + + it('rejects template values which do not match the template schema definition', async () => { + const response = await request(app).post('/v1/jobs').send({ + template, + values: {}, + }); + + expect(response.status).toEqual(400); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index fcfd5765a1..f61d2566b9 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -26,13 +26,14 @@ import { RequiredTemplateValues, StageContext, TemplaterBuilder, - PublisherBase, + PublisherBuilder, } from '../scaffolder'; +import { validate, ValidatorResult } from 'jsonschema'; export interface RouterOptions { preparers: PreparerBuilder; templaters: TemplaterBuilder; - publisher: PublisherBase; + publishers: PublisherBuilder; logger: Logger; dockerClient: Docker; @@ -42,11 +43,12 @@ export async function createRouter( options: RouterOptions, ): Promise { const router = Router(); + router.use(express.json()); const { preparers, templaters, - publisher, + publishers, logger: parentLogger, dockerClient, } = options; @@ -83,6 +85,15 @@ export async function createRouter( const values: RequiredTemplateValues & Record = req.body.values; + const validationResult: ValidatorResult = validate( + values, + template.spec.schema, + ); + if (!validationResult.valid) { + res.status(400).json({ errors: validationResult.errors }); + return; + } + const job = jobProcessor.create({ entity: template, values, @@ -114,6 +125,7 @@ export async function createRouter( { name: 'Publish template', handler: async (ctx: StageContext<{ resultDir: string }>) => { + const publisher = publishers.get(ctx.entity); ctx.logger.info('Will now store the template'); const { remoteUrl } = await publisher.publish({ entity: ctx.entity, diff --git a/plugins/scaffolder/README.md b/plugins/scaffolder/README.md index 5893ee86ba..a356cb8ec9 100644 --- a/plugins/scaffolder/README.md +++ b/plugins/scaffolder/README.md @@ -6,5 +6,5 @@ This is the frontend part of the default scaffolder plugin. ## Links -- (Backend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/scaffolder-backend] -- (The Backstage homepage)[https://backstage.io] +- [Backend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/scaffolder-backend) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 822a620c8a..2efc2c270a 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,11 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.18", - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/plugin-catalog": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/plugin-catalog": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@rjsf/core": "^2.1.0", @@ -38,12 +38,12 @@ "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", - "swr": "^0.2.2" + "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", - "@backstage/test-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 4a0d508a2b..f58c8e68d1 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef } from '@backstage/core'; +import { createApiRef, DiscoveryApi } from '@backstage/core'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; export const scaffolderApiRef = createApiRef({ @@ -23,21 +23,15 @@ export const scaffolderApiRef = createApiRef({ }); export class ScaffolderApi { - private apiOrigin: string; - private basePath: string; + private readonly discoveryApi: DiscoveryApi; - constructor({ - apiOrigin, - basePath, - }: { - apiOrigin: string; - basePath: string; - }) { - this.apiOrigin = apiOrigin; - this.basePath = basePath; + constructor(options: { discoveryApi: DiscoveryApi }) { + this.discoveryApi = options.discoveryApi; } /** + * Executes the scaffolding of a component, given a template and its + * parameter values. * * @param template Template entity for the scaffolder to use. New project is going to be created out of this template. * @param values Parameters for the template, e.g. name, description @@ -46,18 +40,19 @@ export class ScaffolderApi { template: TemplateEntityV1alpha1, values: Record, ) { - const url = `${this.apiOrigin}${this.basePath}/jobs`; + const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v1/jobs`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - // TODO(shmidt-i): when repo picker is implemented, take isOrg from it - body: JSON.stringify({ template, values: { ...values, isOrg: true } }), + body: JSON.stringify({ template, values: { ...values } }), }); if (response.status !== 201) { - throw new Error(await response.text()); + const status = `${response.status} ${response.statusText}`; + const body = await response.text(); + throw new Error(`Backend request failed, ${status} ${body.trim()}`); } const { id } = await response.json(); @@ -65,9 +60,8 @@ export class ScaffolderApi { } async getJob(jobId: string) { - const url = `${this.apiOrigin}${this.basePath}/job/${encodeURIComponent( - jobId, - )}`; + const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); + const url = `${baseUrl}/v1/job/${encodeURIComponent(jobId)}`; return fetch(url).then(x => x.json()); } } diff --git a/plugins/scaffolder/src/components/JobStage/JobStage.tsx b/plugins/scaffolder/src/components/JobStage/JobStage.tsx index 1e9adafb18..0baba0c9b9 100644 --- a/plugins/scaffolder/src/components/JobStage/JobStage.tsx +++ b/plugins/scaffolder/src/components/JobStage/JobStage.tsx @@ -13,16 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { + Accordion, + AccordionDetails, + AccordionSummary, Box, - ExpansionPanel, - ExpansionPanelDetails, - ExpansionPanelSummary, + CircularProgress, LinearProgress, Typography, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import ExpandLessIcon from '@material-ui/icons/ExpandLess'; import cn from 'classnames'; import moment from 'moment'; import React, { Suspense, useEffect, useState } from 'react'; @@ -32,18 +35,17 @@ const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); moment.relativeTimeThreshold('ss', 0); const useStyles = makeStyles(theme => ({ - expansionPanelDetails: { + accordionDetails: { padding: 0, }, button: { order: -1, - marginRight: 0, - marginLeft: '-20px', + margin: '0 1em 0 -20px', }, cardContent: { backgroundColor: theme.palette.background.default, }, - expansionPanel: { + accordion: { position: 'relative', '&:after': { pointerEvents: 'none', @@ -98,18 +100,18 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { : null; return ( - ] ?? classes.neutral, )} expanded={expanded} onChange={(_, newState) => setExpanded(newState)} > - } + : } aria-controls={`panel-${name}-content`} id={`panel-${name}-header`} IconButtonProps={{ @@ -117,10 +119,11 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { }} > - {name} {timeElapsed && `(${timeElapsed})`} + {name} {timeElapsed && `(${timeElapsed})`}{' '} + {startedAt && !endedAt && } - - + + {log.length === 0 ? ( No logs available for this step ) : ( @@ -130,7 +133,7 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { )} - - + + ); }; diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx index c6ed55c92c..87b70a9553 100644 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx @@ -51,7 +51,7 @@ export const JobStatusModal = ({ return ( - Creating component... + Creating Component... {!job ? ( @@ -72,7 +72,7 @@ export const JobStatusModal = ({ {entity && ( Create new software components using standard templates. Different @@ -95,22 +97,22 @@ export const ScaffolderPage: React.FC<{}> = () => { Shoot! Looks like you don't have any templates. Check out the documentation{' '} - + here! )} {error && ( - + Oops! Something went wrong loading the templates: {error.message} - + )} {templates && templates?.length > 0 && templates.map(template => { return ( - + ); diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 7d38c6f223..470fef1148 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -23,8 +23,8 @@ const useStyles = makeStyles(theme => ({ header: { color: theme.palette.common.white, padding: theme.spacing(2, 2, 6), - backgroundImage: (props: { gradientStart: string; gradientStop: string }) => - `linear-gradient(-137deg, ${props.gradientStart} 0%, ${props.gradientStop} 100%)`, + backgroundImage: (props: { backgroundImage: string }) => + props.backgroundImage, }, content: { padding: theme.spacing(2), @@ -56,8 +56,7 @@ export const TemplateCard = ({ name, }: TemplateCardProps) => { const theme = pageTheme[type] ?? pageTheme.other; - const [gradientStart, gradientStop] = theme.gradient.colors; - const classes = useStyles({ gradientStart, gradientStop }); + const classes = useStyles({ backgroundImage: theme.backgroundImage }); const href = generatePath(templateRoute.path, { templateName: name }); return ( diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 05cc249e41..e68c795bd6 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -67,6 +67,11 @@ const OWNER_REPO_SCHEMA = { title: 'Store path', description: 'GitHub store path in org/repo format', }, + access: { + type: 'string' as const, + title: 'Access', + description: 'Who should have access, in org/team or user format', + }, }, }; @@ -91,8 +96,12 @@ export const TemplatePage = () => { const handleClose = () => setJobId(null); const handleCreate = async () => { - const job = await scaffolderApi.scaffold(template!, formState); - setJobId(job); + try { + const job = await scaffolderApi.scaffold(template!, formState); + setJobId(job); + } catch (e) { + errorApi.post(e); + } }; const [entity, setEntity] = React.useState( @@ -136,7 +145,7 @@ export const TemplatePage = () => { } return ( - +
{ /> )} {template && ( - + new ScaffolderApi({ discoveryApi }), + }), + ], register({ router }) { router.addRoute(rootRoute, ScaffolderPage); router.addRoute(templateRoute, TemplatePage); diff --git a/plugins/sentry-backend/package.json b/plugins/sentry-backend/package.json index e199dd2f1d..ffdcc18f7e 100644 --- a/plugins/sentry-backend/package.json +++ b/plugins/sentry-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry-backend", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.18", + "@backstage/backend-common": "^0.1.1-alpha.21", "@types/express": "^4.17.6", - "axios": "^0.19.2", + "axios": "^0.20.0", "compression": "^1.7.4", "cors": "^2.8.5", "express": "^4.17.1", @@ -34,7 +34,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", "jest-fetch-mock": "^3.0.3" }, "files": [ diff --git a/plugins/sentry-backend/src/service/router.ts b/plugins/sentry-backend/src/service/router.ts index e7978042d3..153a8325b5 100644 --- a/plugins/sentry-backend/src/service/router.ts +++ b/plugins/sentry-backend/src/service/router.ts @@ -20,6 +20,8 @@ import { getSentryApiForwarder } from './sentry-api'; export async function createRouter(logger: Logger): Promise { const router = Router(); + router.use(express.json()); + const SENTRY_TOKEN = process.env.SENTRY_TOKEN; if (!SENTRY_TOKEN) { if (process.env.NODE_ENV !== 'development') { diff --git a/plugins/sentry-backend/src/service/sentry-api.ts b/plugins/sentry-backend/src/service/sentry-api.ts index 5c8a9f1aa5..8d35038840 100644 --- a/plugins/sentry-backend/src/service/sentry-api.ts +++ b/plugins/sentry-backend/src/service/sentry-api.ts @@ -26,7 +26,7 @@ export function getRequestHeaders(token: string) { } export function getSentryApiForwarder(token: string, logger: Logger) { - return function fowardRequest( + return function forwardRequest( request: express.Request, response: express.Response, ) { diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 3a6cf54199..e3b900ca8d 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,21 +21,23 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@types/react": "^16.9", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", "react-sparklines": "^1.7.0", "react-use": "^15.3.3", "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/sentry/src/components/Router.tsx b/plugins/sentry/src/components/Router.tsx new file mode 100644 index 0000000000..47d532b4e3 --- /dev/null +++ b/plugins/sentry/src/components/Router.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { Routes, Route } from 'react-router'; +import { WarningPanel } from '@backstage/core'; +import { SentryPluginWidget } from './SentryPluginWidget/SentryPluginWidget'; + +const SENTRY_ANNOTATION = 'sentry.io/project-slug'; + +export const Router = ({ entity }: { entity: Entity }) => { + const projectId = entity.metadata.annotations?.[SENTRY_ANNOTATION]; + + if (!projectId) { + return ( + +
{SENTRY_ANNOTATION}
annotation is missing on the entity. +
+ ); + } + + return ( + + + } + /> + ) + + ); +}; diff --git a/plugins/sentry/src/index.ts b/plugins/sentry/src/index.ts index 50da801a40..a0d3cab1be 100644 --- a/plugins/sentry/src/index.ts +++ b/plugins/sentry/src/index.ts @@ -15,4 +15,5 @@ */ export { plugin } from './plugin'; +export { Router } from './components/Router'; export { SentryPluginWidget as SentryIssuesWidget } from './components/SentryPluginWidget/SentryPluginWidget'; diff --git a/plugins/tech-radar/README.md b/plugins/tech-radar/README.md index fb9d220b8c..cbe4dd22eb 100644 --- a/plugins/tech-radar/README.md +++ b/plugins/tech-radar/README.md @@ -29,71 +29,34 @@ For either simple or advanced installations, you'll need to add the dependency u yarn add @backstage/plugin-tech-radar ``` -### Simple Configuration +### Configuration -In your `apis.ts` set up the simple "out of the box" implementation for Tech Radar: - -```ts -import { ApiHolder, ApiRegistry } from '@backstage/core'; -import { - techRadarApiRef, - TechRadar, -} from '@backstage/plugin-tech-radar'; - -const builder = ApiRegistry.builder(); - -builder.add(techRadarApiRef, new TechRadar({ - width: 1400, - height: 800 -)); - -export default builder.build() as ApiHolder; -``` - -Congrats, you're done! We'll just load it with [example data](src/sampleData.ts) to get you started. Just go to to see it live in action. - -And if you'd like to configure it more, such as providing it with your own data, see the `TechRadarApi` TypeScript interface below for the options: - -```ts -export interface TechRadarComponentProps { - width: number; - height: number; - getData?: () => Promise; - svgProps?: object; -} - -export interface TechRadarApi extends TechRadarComponentProps { - title?: string; - subtitle?: string; -} -``` - -You can see the API directly over at [src/api.ts](./src/api.ts). - -### Advanced Configuration - -This way won't expose an `/tech-radar` path. Instead, you'll need to create your own Backstage plugin and use the Tech Radar as any other React UI component. - -In your Backstage app, run the following command: - -```sh -yarn create-plugin -``` - -In your plugin, in any React component you'd like to import the Tech Radar, do the following: +Modify your app routes to include the Router component exported from the tech radar, for example: ```tsx -import { TechRadarComponent } from '@backstage/plugin-tech-radar'; +import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; -function MyCustomRadar() { - return ; -} +// Inside App component + + {/* other routes ... */} + } + /> + {/* other routes ... */} +; ``` -If you'd like to configure it more, see the `TechRadarComponentProps` TypeScript interface for options: +If you'd like to configure it more, see the `TechRadarPageProps` and `TechRadarComponentProps` types for options: ```ts -export interface TechRadarComponentProps { +export type TechRadarPageProps = TechRadarComponentProps & { + title?: string; + subtitle?: string; + pageTitle?: string; +}; + +export interface TechRadarPageProps { width: number; height: number; getData?: () => Promise; @@ -101,8 +64,6 @@ export interface TechRadarComponentProps { } ``` -You can see the API directly over at [src/api.ts](./src/api.ts). - ## Frequently Asked Questions ### Who created the Tech Radar? @@ -111,7 +72,7 @@ You can see the API directly over at [src/api.ts](./src/api.ts). ### How do I load in my own data? -It's simple. In both the Simple (Backstage plugin) and Advanced (React component) configurations, you can pass through a `getData` prop which expects a `Promise` signature. See more in [src/api.ts](./src/api.ts). +It's simple, you can pass through a `getData` prop which expects a `Promise` signature. Here's an example: @@ -133,42 +94,21 @@ const getHardCodedData = () => ], }); -// Simple -builder.add(techRadarApiRef, new TechRadar({ - width: 1400, - height: 800, - getData: getHardCodedData -)); - -// Advanced - +; ``` ### How do I write tests? You can use the `svgProps` option to pass custom React props to the `` element we create for the Tech Radar. This complements well with the `data-testid` attribute and the `@testing-library/react` library we use in Backstage. -```ts -// Simple -builder.add( - techRadarApiRef, - new TechRadar({ - width: 1400, - height: 800, - svgProps: { - 'data-testid': 'tech-radar-svg', - }, - }), -); - -// Advanced +```tsx ; +/> // Then, in your tests... // const { getByTestId } = render(...); diff --git a/plugins/tech-radar/dev/index.tsx b/plugins/tech-radar/dev/index.tsx index ac19b63a90..92eb6da567 100644 --- a/plugins/tech-radar/dev/index.tsx +++ b/plugins/tech-radar/dev/index.tsx @@ -15,14 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; -import { techRadarApiRef, TechRadar } from '../src'; +import { plugin } from '../src'; -createDevApp() - .registerPlugin(plugin) - .registerApiFactory({ - implements: techRadarApiRef, - deps: {}, - factory: () => new TechRadar({ width: 1500, height: 800 }), - }) - .render(); +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index b2a7be7341..fc29d9c836 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-radar", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/test-utils-core": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "color": "^3.1.2", @@ -35,8 +35,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index 856cf4ff3c..7a6e790136 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { createApiRef } from '@backstage/core'; import { MovedState } from './utils/types'; /** @@ -72,37 +71,3 @@ export interface TechRadarApi extends TechRadarComponentProps { subtitle?: string; pageTitle?: string; } - -export const techRadarApiRef = createApiRef({ - id: 'plugin.techradar', - description: 'Used by the Tech Radar to render the visualization', -}); - -export class TechRadar implements TechRadarApi { - // Default columns - public width: TechRadarApi['width']; - public height: TechRadarApi['height']; - public getData: TechRadarApi['getData']; - public svgProps: TechRadarApi['svgProps']; - public title: TechRadarApi['title']; - public subtitle: TechRadarApi['subtitle']; - public pageTitle: TechRadarApi['pageTitle']; - - constructor(overrideOptions: TechRadarApi) { - const defaultOptions: Partial = { - title: 'Tech Radar', - subtitle: 'Pick the recommended technologies for your projects', - pageTitle: 'Company Radar', - }; - - const options = { ...defaultOptions, ...overrideOptions }; - - this.width = options.width; - this.height = options.height; - this.getData = options.getData; - this.svgProps = options.svgProps; - this.title = options.title; - this.subtitle = options.subtitle; - this.pageTitle = options.pageTitle; - } -} diff --git a/plugins/tech-radar/src/components/RadarComponent.test.tsx b/plugins/tech-radar/src/components/RadarComponent.test.tsx index 2f3fcff198..5591d74b82 100644 --- a/plugins/tech-radar/src/components/RadarComponent.test.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.test.tsx @@ -19,7 +19,7 @@ import { render, waitForElement } from '@testing-library/react'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core'; -import { withLogCollector } from '@backstage/test-utils-core'; +import { withLogCollector } from '@backstage/test-utils'; import GetBBoxPolyfill from '../utils/polyfills/getBBox'; import RadarComponent from './RadarComponent'; diff --git a/plugins/tech-radar/src/components/RadarPage.test.tsx b/plugins/tech-radar/src/components/RadarPage.test.tsx index c318985acd..97fae16380 100644 --- a/plugins/tech-radar/src/components/RadarPage.test.tsx +++ b/plugins/tech-radar/src/components/RadarPage.test.tsx @@ -19,11 +19,10 @@ import { render, waitForElement } from '@testing-library/react'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core'; -import { withLogCollector } from '@backstage/test-utils-core'; import GetBBoxPolyfill from '../utils/polyfills/getBBox'; -import { techRadarApiRef, TechRadar } from '../index'; -import RadarPage from './RadarPage'; +import { RadarPage } from './RadarPage'; +import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils'; describe('RadarPage', () => { beforeAll(() => { @@ -35,24 +34,18 @@ describe('RadarPage', () => { }); it('should render a progress bar', async () => { - const errorApi = { post: () => {} }; - const techRadarApi = new TechRadar({ + const techRadarProps = { width: 1200, height: 800, svgProps: { 'data-testid': 'tech-radar-svg' }, - }); + }; const { getByTestId, queryByTestId } = render( - - - - - , + wrapInTestApp( + + + , + ), ); expect(getByTestId('progress')).toBeInTheDocument(); @@ -61,24 +54,18 @@ describe('RadarPage', () => { }); it('should render a header with a svg', async () => { - const errorApi = { post: () => {} }; - const techRadarApi = new TechRadar({ + const techRadarProps = { width: 1200, height: 800, svgProps: { 'data-testid': 'tech-radar-svg' }, - }); + }; const { getByText, getByTestId } = render( - - - - - , + wrapInTestApp( + + + , + ), ); await waitForElement(() => getByTestId('tech-radar-svg')); @@ -90,78 +77,29 @@ describe('RadarPage', () => { }); it('should call the errorApi if load fails', async () => { - const errorApi = { post: jest.fn() }; + const errorApi = new MockErrorApi({ collect: true }); const techRadarLoadFail = () => Promise.reject(new Error('404 Page Not Found')); - const techRadarApi = new TechRadar({ + const techRadarProps = { width: 1200, height: 800, getData: techRadarLoadFail, svgProps: { 'data-testid': 'tech-radar-svg' }, - }); + }; const { queryByTestId } = render( - - + + , ); await waitForElement(() => !queryByTestId('progress')); - expect(errorApi.post).toHaveBeenCalledTimes(1); - expect(errorApi.post).toHaveBeenCalledWith(new Error('404 Page Not Found')); + expect(errorApi.getErrors()).toEqual([ + { error: new Error('404 Page Not Found'), context: undefined }, + ]); expect(queryByTestId('tech-radar-svg')).not.toBeInTheDocument(); }); - - it('should not render without errorApiRef', () => { - const techRadarApi = new TechRadar({ - width: 1200, - height: 800, - }); - - expect( - withLogCollector(['error'], () => { - expect(() => { - render( - - - - - , - ); - }).toThrow(); - }).error[0], - ).toMatch( - /^Error: Uncaught \[Error: No implementation available for apiRef{core.error}\]/, - ); - }); - - it('should not render without techRadarApiRef', () => { - const errorApi = { post: () => {} }; - - expect( - withLogCollector(['error'], () => { - expect(() => { - render( - - - - - , - ); - }).toThrow(); - }).error[0], - ).toMatch( - /^Error: Uncaught \[Error: No implementation available for apiRef{plugin.techradar}\]/, - ); - }); }); diff --git a/plugins/tech-radar/src/components/RadarPage.tsx b/plugins/tech-radar/src/components/RadarPage.tsx index 1aefb72eca..519c9afeee 100644 --- a/plugins/tech-radar/src/components/RadarPage.tsx +++ b/plugins/tech-radar/src/components/RadarPage.tsx @@ -24,36 +24,46 @@ import { HeaderLabel, SupportButton, pageTheme, - useApi, } from '@backstage/core'; import RadarComponent from '../components/RadarComponent'; -import { techRadarApiRef, TechRadarApi } from '../api'; +import { TechRadarComponentProps } from '../api'; -const RadarPage = (): JSX.Element => { - const techRadarApi = useApi(techRadarApiRef); - - return ( - -
- - -
- - - - This is used for visualizing the official guidelines of different - areas of software development such as languages, frameworks, - infrastructure and processes. - - - - - - - - -
- ); +export type TechRadarPageProps = TechRadarComponentProps & { + title?: string; + subtitle?: string; + pageTitle?: string; }; -export default RadarPage; +export const RadarPage = ({ + title, + subtitle, + pageTitle, + ...props +}: TechRadarPageProps): JSX.Element => ( + +
+ + +
+ + + + This is used for visualizing the official guidelines of different + areas of software development such as languages, frameworks, + infrastructure and processes. + + + + + + + + +
+); + +RadarPage.defaultProps = { + title: 'Tech Radar', + subtitle: 'Pick the recommended technologies for your projects', + pageTitle: 'Company Radar', +}; diff --git a/plugins/tech-radar/src/index.ts b/plugins/tech-radar/src/index.ts index df22e57558..d7b4921e9a 100644 --- a/plugins/tech-radar/src/index.ts +++ b/plugins/tech-radar/src/index.ts @@ -16,6 +16,8 @@ export { plugin } from './plugin'; +export { RadarPage as Router } from './components/RadarPage'; + /** * The TypeScript API for configuring Tech Radar. */ diff --git a/plugins/tech-radar/src/plugin.ts b/plugins/tech-radar/src/plugin.ts index 3497a66f5d..d8dc41af2e 100644 --- a/plugins/tech-radar/src/plugin.ts +++ b/plugins/tech-radar/src/plugin.ts @@ -15,11 +15,7 @@ */ import { createPlugin } from '@backstage/core'; -import RadarPage from './components/RadarPage'; export const plugin = createPlugin({ id: 'tech-radar', - register({ router }) { - router.registerRoute('/tech-radar', RadarPage); - }, }); diff --git a/plugins/techdocs-backend/.eslintrc.js b/plugins/techdocs-backend/.eslintrc.js index 16a033dbc6..595853b48b 100644 --- a/plugins/techdocs-backend/.eslintrc.js +++ b/plugins/techdocs-backend/.eslintrc.js @@ -1,3 +1,4 @@ module.exports = { extends: [require.resolve('@backstage/cli/config/eslint.backend')], + ignorePatterns: ['static/**'], }; diff --git a/plugins/techdocs-backend/README.md b/plugins/techdocs-backend/README.md index ad98d07f95..2185c3b3e4 100644 --- a/plugins/techdocs-backend/README.md +++ b/plugins/techdocs-backend/README.md @@ -6,9 +6,9 @@ This is the backend part of the techdocs plugin. This backend plugin can be started in a standalone mode from directly in this package with `yarn start`. However, it will have limited functionality and that process is -most convenient when developing the catalog backend plugin itself. +most convenient when developing the techdocs backend plugin itself. -To evaluate the catalog and have a greater amount of functionality available, instead do +To evaluate TechDocs and have a greater amount of functionality available, instead do ```bash # in one terminal window, run this from from the very root of the Backstage project @@ -16,7 +16,32 @@ cd packages/backend yarn start ``` +## What techdocs-backend does + +This plugin is the backend part of the techdocs plugin. It provides building and serving of your docs without having to use another service and hosting provider. To use it set your techdocs storageUrl in your `app-config.yml` to `http://localhost:7000/techdocs/static/docs`. + +```yaml +techdocs: + storageUrl: http://localhost:7000/techdocs/static/docs +``` + +## Extending techdocs-backend + +Currently the build process of techdocs-backend is split up in these three stages. + +- Preparers +- Generators +- Publishers + +Preparers read your entity data and creates a working directory with your documentation source code. For example if you have set your `backstage.io/techdocs-ref` to `github:https://github.com/spotify/backstage.git` it will clone that repository to a temp folder and pass that on to the generator. + +Generators takes the prepared source and runs the `techdocs-container` on it. It then passes on the output folder of that build to the publisher. + +Publishers gets a folder path from the generator and publish it to your storage solution. Currently the only built in storage solution is a folder called `static/docs` inside the techdocs-backend plugin. + +Any of these can be extended. If we want to publish to a external static file server using rsync for example that can be done by creating a rsync publisher. _(Keep in mind that if you want techdocs-backend to initiate a build this would also require techdocs-backend to act as a proxy, which is not yet implemented.)_ + ## Links -- (Frontend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/techdocs] -- (The Backstage homepage)[https://backstage.io] +- [Frontend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/techdocs) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/techdocs-backend/examples/documented-component/docs/sub-page.md b/plugins/techdocs-backend/examples/documented-component/docs/sub-page.md new file mode 100644 index 0000000000..bbd98558e0 --- /dev/null +++ b/plugins/techdocs-backend/examples/documented-component/docs/sub-page.md @@ -0,0 +1 @@ +# Another page in our documentation diff --git a/plugins/techdocs-backend/examples/documented-component/documented-component.yaml b/plugins/techdocs-backend/examples/documented-component/documented-component.yaml index d5a1214b15..045ff89013 100644 --- a/plugins/techdocs-backend/examples/documented-component/documented-component.yaml +++ b/plugins/techdocs-backend/examples/documented-component/documented-component.yaml @@ -4,7 +4,7 @@ metadata: name: documented-component description: A Service with TechDocs documentation annotations: - backstage.io/techdocs-ref: 'dir:./' + backstage.io/techdocs-ref: 'github:https://github.com/spotify/backstage/blob/master/plugins/techdocs-backend/examples/documented-component' spec: type: service lifecycle: experimental diff --git a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml index 6a3f85d020..3b48e8edff 100644 --- a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml +++ b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml @@ -2,6 +2,7 @@ site_name: 'example-docs' nav: - Home: index.md + - SubPage: sub-page.md plugins: - techdocs-core diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 8a5a9fd10a..476b321e1e 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -17,25 +17,27 @@ "test": "backstage-cli test", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean", - "mock-data": "./scripts/mock-data.sh" + "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.18", - "@backstage/catalog-model": "^0.1.1-alpha.18", - "@backstage/config": "^0.1.1-alpha.18", + "@backstage/backend-common": "^0.1.1-alpha.21", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/config": "^0.1.1-alpha.21", "@types/dockerode": "^2.5.34", "@types/express": "^4.17.6", + "default-branch": "^1.0.8", "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.1", + "git-url-parse": "^11.2.0", "knex": "^0.21.1", "node-fetch": "^2.6.0", + "nodegit": "^0.27.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", "@types/node-fetch": "^2.5.7", "supertest": "^4.0.2" }, diff --git a/plugins/techdocs-backend/scripts/mock-data.sh b/plugins/techdocs-backend/scripts/mock-data.sh deleted file mode 100755 index 0630490694..0000000000 --- a/plugins/techdocs-backend/scripts/mock-data.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -for URL in \ - 'documented-component/documented-component.yaml' \ -; do \ - curl \ - --location \ - --request POST 'localhost:7000/catalog/locations' \ - --header 'Content-Type: application/json' \ - --data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/examples/${URL}\"}" - echo -done diff --git a/plugins/techdocs-backend/src/helpers.ts b/plugins/techdocs-backend/src/helpers.ts new file mode 100644 index 0000000000..14f4d490ba --- /dev/null +++ b/plugins/techdocs-backend/src/helpers.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import os from 'os'; +import path from 'path'; +import parseGitUrl from 'git-url-parse'; +import { Clone, Repository } from 'nodegit'; +import fs from 'fs-extra'; +// @ts-ignore +import defaultBranch from 'default-branch'; +import { Entity } from '@backstage/catalog-model'; +import { InputError } from '@backstage/backend-common'; +import { RemoteProtocol } from './techdocs/stages/prepare/types'; +import { Logger } from 'winston'; + +export type ParsedLocationAnnotation = { + type: RemoteProtocol; + target: string; +}; + +export const parseReferenceAnnotation = ( + annotationName: string, + entity: Entity, +): ParsedLocationAnnotation => { + const annotation = entity.metadata.annotations?.[annotationName]; + + if (!annotation) { + throw new InputError( + `No location annotation provided in entity: ${entity.metadata.name}`, + ); + } + + // split on the first colon for the protocol and the rest after the first split + // is the location. + const [type, target] = annotation.split(/:(.+)/) as [ + RemoteProtocol?, + string?, + ]; + + if (!type || !target) { + throw new InputError( + `Failure to parse either protocol or location for entity: ${entity.metadata.name}`, + ); + } + + return { + type, + target, + }; +}; + +export const getLocationForEntity = ( + entity: Entity, +): ParsedLocationAnnotation => { + const { type, target } = parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + entity, + ); + + switch (type) { + case 'github': + return { type, target }; + case 'dir': + if (path.isAbsolute(target)) return { type, target }; + + return parseReferenceAnnotation( + 'backstage.io/managed-by-location', + entity, + ); + default: + throw new Error(`Invalid reference annotation ${type}`); + } +}; + +export const getGitHubRepositoryTempFolder = async ( + repositoryUrl: string, +): Promise => { + const parsedGitLocation = parseGitUrl(repositoryUrl); + // removes .git from git location path + parsedGitLocation.git_suffix = false; + + if (!parsedGitLocation.ref) { + parsedGitLocation.ref = await defaultBranch( + parsedGitLocation.toString('https'), + ); + } + + return path.join( + // fs.realpathSync fixes a problem with macOS returning a path that is a symlink + fs.realpathSync(os.tmpdir()), + 'backstage-repo', + parsedGitLocation.source, + parsedGitLocation.owner, + parsedGitLocation.name, + parsedGitLocation.ref, + ); +}; + +export const checkoutGithubRepository = async ( + repoUrl: string, + logger: Logger, +): Promise => { + const parsedGitLocation = parseGitUrl(repoUrl); + const repositoryTmpPath = await getGitHubRepositoryTempFolder(repoUrl); + + // TODO: Should propably not be hardcoded names of env variables, but seems too hard to access config down here + const user = process.env.GITHUB_PRIVATE_TOKEN_USER || ''; + const token = process.env.GITHUB_PRIVATE_TOKEN || ''; + + if (fs.existsSync(repositoryTmpPath)) { + try { + const repository = await Repository.open(repositoryTmpPath); + const currentBranchName = ( + await repository.getCurrentBranch() + ).shorthand(); + await repository.fetch('origin'); + await repository.mergeBranches( + currentBranchName, + `origin/${currentBranchName}`, + ); + return repositoryTmpPath; + } catch (e) { + logger.info( + `Found error "${e.message}" in cached repository "${repoUrl}" when getting latest changes. Removing cached repository.`, + ); + fs.removeSync(repositoryTmpPath); + } + } + + if (user && token) { + parsedGitLocation.token = `${user}:${token}`; + } + + const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + + fs.mkdirSync(repositoryTmpPath, { recursive: true }); + await Clone.clone(repositoryCheckoutUrl, repositoryTmpPath); + + return repositoryTmpPath; +}; + +export const getLastCommitTimestamp = async ( + repositoryUrl: string, + logger: Logger, +): Promise => { + const repositoryLocation = await checkoutGithubRepository( + repositoryUrl, + logger, + ); + + const repository = await Repository.open(repositoryLocation); + const commit = await repository.getReferenceCommit('HEAD'); + + return commit.date().getTime(); +}; diff --git a/plugins/techdocs-backend/src/service/helpers.ts b/plugins/techdocs-backend/src/service/helpers.ts new file mode 100644 index 0000000000..60c76e6384 --- /dev/null +++ b/plugins/techdocs-backend/src/service/helpers.ts @@ -0,0 +1,132 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Docker from 'dockerode'; +import { Logger } from 'winston'; +import { Entity } from '@backstage/catalog-model'; +import { + PreparerBuilder, + PublisherBase, + GeneratorBuilder, + PreparerBase, + GeneratorBase, +} from '../techdocs'; +import { BuildMetadataStorage } from '../storage'; +import { getLocationForEntity, getLastCommitTimestamp } from '../helpers'; + +const getEntityId = (entity: Entity) => { + return `${entity.kind}:${entity.metadata.namespace ?? ''}:${ + entity.metadata.name + }`; +}; + +type DocsBuilderArguments = { + preparers: PreparerBuilder; + generators: GeneratorBuilder; + publisher: PublisherBase; + entity: Entity; + logger: Logger; + dockerClient: Docker; +}; + +export class DocsBuilder { + private preparer: PreparerBase; + private generator: GeneratorBase; + private publisher: PublisherBase; + private entity: Entity; + private logger: Logger; + private dockerClient: Docker; + + constructor({ + preparers, + generators, + publisher, + entity, + logger, + dockerClient, + }: DocsBuilderArguments) { + this.preparer = preparers.get(entity); + this.generator = generators.get(entity); + this.publisher = publisher; + this.entity = entity; + this.logger = logger; + this.dockerClient = dockerClient; + } + + public async build() { + this.logger.info( + `[TechDocs] Running preparer on entity ${getEntityId(this.entity)}`, + ); + const preparedDir = await this.preparer.prepare(this.entity); + + this.logger.info( + `[TechDocs] Running generator on entity ${getEntityId(this.entity)}`, + ); + const { resultDir } = await this.generator.run({ + directory: preparedDir, + dockerClient: this.dockerClient, + }); + + this.logger.info( + `[TechDocs] Running publisher on entity ${getEntityId(this.entity)}`, + ); + await this.publisher.publish({ + entity: this.entity, + directory: resultDir, + }); + + if (!this.entity.metadata.uid) { + throw new Error( + 'Trying to build documentation for entity not in service catalog', + ); + } + + new BuildMetadataStorage(this.entity.metadata.uid).storeBuildTimestamp(); + } + + public async docsUpToDate() { + if (!this.entity.metadata.uid) { + throw new Error( + 'Trying to build documentation for entity not in service catalog', + ); + } + + const buildMetadataStorage = new BuildMetadataStorage( + this.entity.metadata.uid, + ); + const { type, target } = getLocationForEntity(this.entity); + + // Should probably be broken out and handled per type later. Doing this for now since we only support github age checks + if (type === 'github') { + const lastCommit = await getLastCommitTimestamp(target, this.logger); + const storageTimeStamp = buildMetadataStorage.getTimestamp(); + + // Check if documentation source is newer than what we have + if (storageTimeStamp && storageTimeStamp >= lastCommit) { + this.logger.debug( + `[TechDocs] Docs for entity ${getEntityId( + this.entity, + )} is up to date.`, + ); + return true; + } + } + + this.logger.debug( + `[TechDocs] Docs for entity ${getEntityId(this.entity)} was outdated.`, + ); + return false; + } +} diff --git a/plugins/techdocs-backend/src/service/router.test.ts b/plugins/techdocs-backend/src/service/router.test.ts deleted file mode 100644 index b28010e059..0000000000 --- a/plugins/techdocs-backend/src/service/router.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { getVoidLogger } from '@backstage/backend-common'; -import { Preparers, LocalPublish, Generators } from '../techdocs'; -import { ConfigReader } from '@backstage/config'; -import Docker from 'dockerode'; -import express from 'express'; -import request from 'supertest'; -import { createRouter } from './router'; - -describe('createRouter', () => { - let app: express.Express; - - beforeAll(async () => { - const router = await createRouter({ - preparers: new Preparers(), - generators: new Generators(), - publisher: new LocalPublish(), - logger: getVoidLogger(), - dockerClient: new Docker(), - config: ConfigReader.fromConfigs([]), - }); - app = express().use(router); - }); - - describe('GET /', () => { - it('does not explode', async () => { - const response = await request(app).get('/'); - - expect(response.status).toEqual(200); - expect(response.text).toEqual('Hello TechDocs Backend'); - }); - }); -}); diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 874ab7c89c..e0a066c168 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -19,7 +19,6 @@ import express from 'express'; import Knex from 'knex'; import fetch from 'node-fetch'; import { Config } from '@backstage/config'; -import path from 'path'; import Docker from 'dockerode'; import { GeneratorBuilder, @@ -27,7 +26,9 @@ import { PublisherBase, LocalPublish, } from '../techdocs'; +import { resolvePackagePath } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; +import { DocsBuilder } from './helpers'; type RouterOptions = { preparers: PreparerBuilder; @@ -39,53 +40,51 @@ type RouterOptions = { dockerClient: Docker; }; +const staticDocsDir = resolvePackagePath( + '@backstage/plugin-techdocs-backend', + 'static/docs', +); + export async function createRouter({ preparers, generators, publisher, config, dockerClient, + logger, }: RouterOptions): Promise { const router = Router(); - router.get('/', async (_, res) => { - res.status(200).send('Hello TechDocs Backend'); - }); - - // TODO: This route should not exist in the future - router.get('/buildall', async (_, res) => { + router.get('/docs/:kind/:namespace/:name/*', async (req, res) => { const baseUrl = config.getString('backend.baseUrl'); - const entitiesResponse = (await ( - await fetch(`${baseUrl}/catalog/entities`) - ).json()) as Entity[]; + const storageUrl = config.getString('techdocs.storageUrl'); - const entitiesWithDocs = entitiesResponse.filter( - entity => entity.metadata.annotations?.['backstage.io/techdocs-ref'], - ); + const { kind, namespace, name } = req.params; - entitiesWithDocs.forEach(async entity => { - const preparer = preparers.get(entity); - const generator = generators.get(entity); + const entity = (await ( + await fetch( + `${baseUrl}/catalog/entities/by-name/${kind}/${namespace}/${name}`, + ) + ).json()) as Entity; - const { resultDir } = await generator.run({ - directory: await preparer.prepare(entity), - dockerClient, - }); - - publisher.publish({ - entity, - directory: resultDir, - }); + const docsBuilder = new DocsBuilder({ + preparers, + generators, + publisher, + dockerClient, + logger, + entity, }); - res.send('Successfully generated documentation'); + if (!(await docsBuilder.docsUpToDate())) { + await docsBuilder.build(); + } + + return res.redirect(`${storageUrl}${req.path.replace('/docs', '')}`); }); if (publisher instanceof LocalPublish) { - router.use( - '/static/docs/', - express.static(path.resolve(__dirname, `../../static/docs`)), - ); + router.use('/static/docs', express.static(staticDocsDir)); } return router; diff --git a/plugins/techdocs-backend/src/service/standaloneServer.ts b/plugins/techdocs-backend/src/service/standaloneServer.ts index 76de973875..93233af4c0 100644 --- a/plugins/techdocs-backend/src/service/standaloneServer.ts +++ b/plugins/techdocs-backend/src/service/standaloneServer.ts @@ -41,14 +41,14 @@ export async function startStandaloneServer( logger.debug('Creating application...'); const preparers = new Preparers(); - const directoryPreparer = new DirectoryPreparer(); + const directoryPreparer = new DirectoryPreparer(logger); preparers.register('dir', directoryPreparer); const generators = new Generators(); - const techdocsGenerator = new TechdocsGenerator(); + const techdocsGenerator = new TechdocsGenerator(logger); generators.register('techdocs', techdocsGenerator); - const publisher = new LocalPublish(); + const publisher = new LocalPublish(logger); const dockerClient = new Docker(); diff --git a/plugins/techdocs-backend/src/storage/BuildMetadataStorage.ts b/plugins/techdocs-backend/src/storage/BuildMetadataStorage.ts new file mode 100644 index 0000000000..b82ec1291a --- /dev/null +++ b/plugins/techdocs-backend/src/storage/BuildMetadataStorage.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +type buildInfo = { + // uid: timestamp + [key: string]: number; +}; + +const builds = {} as buildInfo; + +export class BuildMetadataStorage { + public entityUid: string; + private builds: buildInfo; + + constructor(entityUid: string) { + this.entityUid = entityUid; + this.builds = builds; + } + + storeBuildTimestamp() { + this.builds[this.entityUid] = Date.now(); + } + + getTimestamp() { + return this.builds[this.entityUid]; + } +} diff --git a/plugins/techdocs-backend/src/storage/index.ts b/plugins/techdocs-backend/src/storage/index.ts new file mode 100644 index 0000000000..3d37f69679 --- /dev/null +++ b/plugins/techdocs-backend/src/storage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './BuildMetadataStorage'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/generators.test.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/generators.test.ts new file mode 100644 index 0000000000..b339aef9f8 --- /dev/null +++ b/plugins/techdocs-backend/src/techdocs/stages/generate/generators.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Generators, TechdocsGenerator } from './'; +import { getVoidLogger } from '@backstage/backend-common'; + +const logger = getVoidLogger(); + +const mockEntity = { + apiVersion: 'version', + kind: 'TestKind', + metadata: { + name: 'testName', + }, +}; + +describe('generators', () => { + it('should return error if no generator is registered', async () => { + const generators = new Generators(); + + expect(() => generators.get(mockEntity)).toThrowError( + 'No generator registered for entity: "techdocs"', + ); + }); + + it('should return correct registered generator', async () => { + const generators = new Generators(); + const techdocs = new TechdocsGenerator(logger); + + generators.register('techdocs', techdocs); + + expect(generators.get(mockEntity)).toBe(techdocs); + }); +}); diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/generators.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/generators.ts index da9d3c418b..f28a169942 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/generators.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/generate/generators.ts @@ -15,32 +15,29 @@ */ import { - GeneratorBase, - SupportedGeneratorKey, - GeneratorBuilder, - } from './types'; - - import { Entity } from '@backstage/catalog-model'; - import { getGeneratorKey } from './helpers'; - - export class Generators implements GeneratorBuilder { - private generatorMap = new Map(); - - register(templaterKey: SupportedGeneratorKey, templater: GeneratorBase) { - this.generatorMap.set(templaterKey, templater); - } - - get(entity: Entity): GeneratorBase { - const generatorKey = getGeneratorKey(entity); - const generator = this.generatorMap.get(generatorKey); - - if (!generator) { - throw new Error( - `No generator registered for entity: "${generatorKey}"`, - ); - } - - return generator; - } + GeneratorBase, + SupportedGeneratorKey, + GeneratorBuilder, +} from './types'; + +import { Entity } from '@backstage/catalog-model'; +import { getGeneratorKey } from './helpers'; + +export class Generators implements GeneratorBuilder { + private generatorMap = new Map(); + + register(generatorKey: SupportedGeneratorKey, generator: GeneratorBase) { + this.generatorMap.set(generatorKey, generator); } - \ No newline at end of file + + get(entity: Entity): GeneratorBase { + const generatorKey = getGeneratorKey(entity); + const generator = this.generatorMap.get(generatorKey); + + if (!generator) { + throw new Error(`No generator registered for entity: "${generatorKey}"`); + } + + return generator; + } +} diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts new file mode 100644 index 0000000000..799e79ef67 --- /dev/null +++ b/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Stream, { PassThrough } from 'stream'; +import os from 'os'; +import Docker from 'dockerode'; +import { runDockerContainer, getGeneratorKey } from './helpers'; + +const mockEntity = { + apiVersion: 'version', + kind: 'TestKind', + metadata: { + name: 'testName', + }, +}; + +const mockDocker = new Docker() as jest.Mocked; + +describe('helpers', () => { + describe('getGeneratorKey', () => { + it('should return techdocs as the only generator key', () => { + const key = getGeneratorKey(mockEntity); + expect(key).toBe('techdocs'); + }); + }); + + describe('runDockerContainer', () => { + beforeEach(() => { + jest.spyOn(mockDocker, 'pull').mockImplementation((async ( + _image: string, + _something: any, + handler: (err: Error | undefined, stream: PassThrough) => void, + ) => { + const mockStream = new PassThrough(); + handler(undefined, mockStream); + mockStream.end(); + }) as any); + + jest + .spyOn(mockDocker, 'run') + .mockResolvedValue([{ Error: null, StatusCode: 0 }]); + }); + + const imageName = 'spotify/techdocs'; + const args = ['build', '-d', '/result']; + const docsDir = os.tmpdir(); + const resultDir = os.tmpdir(); + + it('should pull the techdocs docker container', async () => { + await runDockerContainer({ + imageName, + args, + docsDir, + resultDir, + dockerClient: mockDocker, + }); + + expect(mockDocker.pull).toHaveBeenCalledWith( + imageName, + {}, + expect.any(Function), + ); + }); + + it('should run the techdocs docker container', async () => { + await runDockerContainer({ + imageName, + args, + docsDir, + resultDir, + dockerClient: mockDocker, + }); + + expect(mockDocker.run).toHaveBeenCalledWith( + imageName, + args, + expect.any(Stream), + { + Volumes: { + '/content': {}, + '/result': {}, + }, + WorkingDir: '/content', + HostConfig: { + Binds: [`${docsDir}:/content`, `${resultDir}:/result`], + }, + }, + ); + }); + }); +}); diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts index 7dc4403977..88667f9903 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts @@ -47,6 +47,16 @@ export async function runDockerContainer({ dockerClient, createOptions, }: RunDockerContainerOptions) { + await new Promise((resolve, reject) => { + dockerClient.pull(imageName, {}, (err, stream) => { + if (err) return reject(err); + stream.pipe(logStream, { end: false }); + stream.on('end', () => resolve()); + stream.on('error', (error: Error) => reject(error)); + return undefined; + }); + }); + const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run( imageName, args, diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/index.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/index.ts index 5c086a976a..3a8b9ad828 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/index.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/generate/index.ts @@ -15,4 +15,4 @@ */ export { TechdocsGenerator } from './techdocs'; export { Generators } from './generators'; -export type { GeneratorBuilder } from './types'; +export type { GeneratorBuilder, GeneratorBase } from './types'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts index d6650fb744..40cd7590b6 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts @@ -13,36 +13,59 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; +import { Logger } from 'winston'; + import { GeneratorBase, GeneratorRunOptions, GeneratorRunResult, } from './types'; import { runDockerContainer } from './helpers'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; export class TechdocsGenerator implements GeneratorBase { + private readonly logger: Logger; + + constructor(logger: Logger) { + this.logger = logger; + } + public async run({ directory, logStream, dockerClient, }: GeneratorRunOptions): Promise { - const resultDir = fs.mkdtempSync(path.join(os.tmpdir(), `techdocs-tmp-`)); - - await runDockerContainer({ - imageName: 'spotify/techdocs', - args: ['build', '-d', '/result'], - logStream, - docsDir: directory, - resultDir, - dockerClient, - }); - - console.log( - `[TechDocs]: Successfully generated docs from ${directory} into ${resultDir}`, + const tmpdirPath = os.tmpdir(); + // Fixes a problem with macOS returning a path that is a symlink + const tmpdirResolvedPath = fs.realpathSync(tmpdirPath); + const resultDir = fs.mkdtempSync( + path.join(tmpdirResolvedPath, 'techdocs-tmp-'), ); + + try { + await runDockerContainer({ + imageName: 'spotify/techdocs', + args: ['build', '-d', '/result'], + logStream, + docsDir: directory, + resultDir, + dockerClient, + }); + this.logger.info( + `[TechDocs]: Successfully generated docs from ${directory} into ${resultDir}`, + ); + } catch (error) { + this.logger.debug( + `[TechDocs]: Failed to generate docs from ${directory} into ${resultDir}`, + ); + throw new Error( + `Failed to generate docs from ${directory} into ${resultDir} with error ${error.message}`, + ); + } + return { resultDir }; } } diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/types.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/types.ts index 4292aa68f6..398e4dcdd0 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/types.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/generate/types.ts @@ -26,7 +26,7 @@ export type GeneratorRunResult = { }; /** - * The values that the generator will recieve. The directory of the + * The values that the generator will receive. The directory of the * uncompiled documentation, with the values from the frontend. A dedicated log stream and a docker * client to run any generator on top of your directory. */ diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts index 8a99a8b59a..e2b94d7e88 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts @@ -14,6 +14,24 @@ * limitations under the License. */ import { DirectoryPreparer } from './dir'; +import { getVoidLogger } from '@backstage/backend-common'; +import { checkoutGithubRepository } from '../../../helpers'; + +function normalizePath(path: string) { + return path + .replace(/^[a-z]:/i, '') + .split('\\') + .join('/'); +} + +jest.mock('../../../helpers', () => ({ + ...jest.requireActual<{}>('../../../helpers'), + checkoutGithubRepository: jest.fn( + () => '/tmp/backstage-repo/org/name/branch/', + ), +})); + +const logger = getVoidLogger(); const createMockEntity = (annotations: {}) => { return { @@ -30,7 +48,7 @@ const createMockEntity = (annotations: {}) => { describe('directory preparer', () => { it('should merge managed-by-location and techdocs-ref when techdocs-ref is relative', async () => { - const directoryPreparer = new DirectoryPreparer(); + const directoryPreparer = new DirectoryPreparer(logger); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -38,13 +56,13 @@ describe('directory preparer', () => { 'backstage.io/techdocs-ref': 'dir:./our-documentation', }); - expect(await directoryPreparer.prepare(mockEntity)).toEqual( + expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual( '/directory/our-documentation', ); }); it('should merge managed-by-location and techdocs-ref when techdocs-ref is absolute', async () => { - const directoryPreparer = new DirectoryPreparer(); + const directoryPreparer = new DirectoryPreparer(logger); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -52,8 +70,23 @@ describe('directory preparer', () => { 'backstage.io/techdocs-ref': 'dir:/our-documentation/techdocs', }); - expect(await directoryPreparer.prepare(mockEntity)).toEqual( + expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual( '/our-documentation/techdocs', ); }); + + it('should merge managed-by-location and techdocs-ref when managed-by-location is a git repository', async () => { + const directoryPreparer = new DirectoryPreparer(logger); + + const mockEntity = createMockEntity({ + 'backstage.io/managed-by-location': + 'github:https://github.com/spotify/backstage/blob/master/catalog-info.yaml', + 'backstage.io/techdocs-ref': 'dir:./docs', + }); + + expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual( + '/tmp/backstage-repo/org/name/branch/docs', + ); + expect(checkoutGithubRepository).toHaveBeenCalledTimes(1); + }); }); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts index e61a3e48ac..5fd4abb4bd 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts @@ -16,23 +16,61 @@ import { PreparerBase } from './types'; import { Entity } from '@backstage/catalog-model'; import path from 'path'; -import { parseReferenceAnnotation } from './helpers'; +import { + parseReferenceAnnotation, + checkoutGithubRepository, +} from '../../../helpers'; +import { InputError } from '@backstage/backend-common'; +import parseGitUrl from 'git-url-parse'; +import { Logger } from 'winston'; export class DirectoryPreparer implements PreparerBase { - prepare(entity: Entity): Promise { - const { location: managedByLocation } = parseReferenceAnnotation( + private readonly logger: Logger; + + constructor(logger: Logger) { + this.logger = logger; + } + + private async resolveManagedByLocationToDir(entity: Entity) { + const { type, target } = parseReferenceAnnotation( 'backstage.io/managed-by-location', entity, ); - const { location: techdocsLocation } = parseReferenceAnnotation( + + this.logger.debug( + `[TechDocs] Building docs for entity with type 'dir' and managed-by-location '${type}'`, + ); + switch (type) { + case 'github': { + const parsedGitLocation = parseGitUrl(target); + const repoLocation = await checkoutGithubRepository( + target, + this.logger, + ); + + return path.dirname( + path.join(repoLocation, parsedGitLocation.filepath), + ); + } + case 'file': + return path.dirname(target); + default: + throw new InputError(`Unable to resolve location type ${type}`); + } + } + + async prepare(entity: Entity): Promise { + const { target } = parseReferenceAnnotation( 'backstage.io/techdocs-ref', entity, ); - const managedByLocationDirectory = path.dirname(managedByLocation); + const managedByLocationDirectory = await this.resolveManagedByLocationToDir( + entity, + ); return new Promise(resolve => { - resolve(path.resolve(managedByLocationDirectory, techdocsLocation)); + resolve(path.resolve(managedByLocationDirectory, target)); }); } } diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts new file mode 100644 index 0000000000..a188e1c986 --- /dev/null +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import { GithubPreparer } from './github'; +import { checkoutGithubRepository } from '../../../helpers'; + +function normalizePath(path: string) { + return path + .replace(/^[a-z]:/i, '') + .split('\\') + .join('/'); +} + +jest.mock('../../../helpers', () => ({ + ...jest.requireActual<{}>('../../../helpers'), + checkoutGithubRepository: jest.fn( + () => '/tmp/backstage-repo/org/name/branch', + ), +})); + +const createMockEntity = (annotations = {}) => { + return { + apiVersion: 'version', + kind: 'TestKind', + metadata: { + name: 'test-component-name', + annotations: { + ...annotations, + }, + }, + }; +}; + +const logger = getVoidLogger(); + +describe('github preparer', () => { + it('should prepare temp docs path from github repo', async () => { + const preparer = new GithubPreparer(logger); + + const mockEntity = createMockEntity({ + 'backstage.io/techdocs-ref': + 'github:https://github.com/spotify/backstage/blob/master/plugins/techdocs-backend/examples/documented-component', + }); + + const tempDocsPath = await preparer.prepare(mockEntity); + expect(checkoutGithubRepository).toHaveBeenCalledTimes(1); + expect(normalizePath(tempDocsPath)).toEqual( + '/tmp/backstage-repo/org/name/branch/plugins/techdocs-backend/examples/documented-component', + ); + }); +}); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts new file mode 100644 index 0000000000..03b0adcb88 --- /dev/null +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import path from 'path'; +import { Entity } from '@backstage/catalog-model'; +import { InputError } from '@backstage/backend-common'; +import { PreparerBase } from './types'; +import parseGitUrl from 'git-url-parse'; +import { + parseReferenceAnnotation, + checkoutGithubRepository, +} from '../../../helpers'; + +import { Logger } from 'winston'; + +export class GithubPreparer implements PreparerBase { + private readonly logger: Logger; + + constructor(logger: Logger) { + this.logger = logger; + } + + async prepare(entity: Entity): Promise { + const { type, target } = parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + entity, + ); + + if (type !== 'github') { + throw new InputError(`Wrong target type: ${type}, should be 'github'`); + } + + try { + const repoPath = await checkoutGithubRepository(target, this.logger); + + const parsedGitLocation = parseGitUrl(target); + return path.join(repoPath, parsedGitLocation.filepath); + } catch (error) { + this.logger.debug(`Repo checkout failed with error ${error.message}`); + throw error; + } + } +} diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/helpers.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/helpers.ts deleted file mode 100644 index 1fd86805ca..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/helpers.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Entity } from '@backstage/catalog-model'; -import { InputError } from '@backstage/backend-common'; -import { RemoteProtocol } from './types'; - -export type ParsedLocationAnnotation = { - protocol: RemoteProtocol; - location: string; -}; - -export const parseReferenceAnnotation = ( - annotationName: string, - entity: Entity, -): ParsedLocationAnnotation => { - const annotation = entity.metadata.annotations?.[annotationName]; - - if (!annotation) { - throw new InputError( - `No location annotation provided in entity: ${entity.metadata.name}`, - ); - } - - // split on the first colon for the protocol and the rest after the first split - // is the location. - const [protocol, location] = annotation.split(/:(.+)/) as [ - RemoteProtocol?, - string?, - ]; - - if (!protocol || !location) { - throw new InputError( - `Failure to parse either protocol or location for entity: ${entity.metadata.name}`, - ); - } - - return { - protocol, - location, - }; -}; diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts index 9f928e7413..48ffa75903 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ export { DirectoryPreparer } from './dir'; +export { GithubPreparer } from './github'; export { Preparers } from './preparers'; -export type { PreparerBuilder } from './types'; +export type { PreparerBuilder, PreparerBase } from './types'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/preparers.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/preparers.ts index 1d5b689d8b..2f0df47de4 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/preparers.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/preparers.ts @@ -16,7 +16,7 @@ import { PreparerBase, RemoteProtocol, PreparerBuilder } from './types'; import { Entity } from '@backstage/catalog-model'; -import { parseReferenceAnnotation } from './helpers'; +import { parseReferenceAnnotation } from '../../../helpers'; export class Preparers implements PreparerBuilder { private preparerMap = new Map(); @@ -26,13 +26,16 @@ export class Preparers implements PreparerBuilder { } get(entity: Entity): PreparerBase { - const { protocol } = parseReferenceAnnotation('backstage.io/techdocs-ref', entity); - const preparer = this.preparerMap.get(protocol); + const { type } = parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + entity, + ); + const preparer = this.preparerMap.get(type); if (!preparer) { - throw new Error(`No preparer registered for type: "${protocol}"`); + throw new Error(`No preparer registered for type: "${type}"`); } return preparer; } -} \ No newline at end of file +} diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts index 8baf6347bb..1ceb884ea8 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts @@ -30,4 +30,4 @@ export type PreparerBuilder = { get(entity: Entity): PreparerBase; }; -export type RemoteProtocol = 'dir' | string; +export type RemoteProtocol = 'dir' | 'github' | 'file'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts index b5a96e4feb..55e0a547e9 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LocalPublish } from './local'; import fs from 'fs-extra'; import path from 'path'; +import { getVoidLogger } from '@backstage/backend-common'; +import { LocalPublish } from './local'; const createMockEntity = (annotations = {}) => { return { @@ -30,9 +31,11 @@ const createMockEntity = (annotations = {}) => { }; }; +const logger = getVoidLogger(); + describe('local publisher', () => { it('should publish generated documentation dir', async () => { - const publisher = new LocalPublish(); + const publisher = new LocalPublish(logger); const mockEntity = createMockEntity(); @@ -48,8 +51,13 @@ describe('local publisher', () => { `../../../../static/docs/${mockEntity.metadata.name}`, ); - expect(fs.existsSync(publishDir)).toBeTruthy(); - expect(fs.existsSync(path.join(publishDir, '/mock-file'))).toBeTruthy(); + const resultDir = path.resolve( + __dirname, + `../../../../static/docs/${mockEntity.kind}/default/${mockEntity.metadata.name}`, + ); + + expect(fs.existsSync(resultDir)).toBeTruthy(); + expect(fs.existsSync(path.join(resultDir, '/mock-file'))).toBeTruthy(); fs.removeSync(publishDir); fs.removeSync(tempDir); diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts index 227ba445ae..e33c6fa17d 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts @@ -13,12 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PublisherBase } from './types'; -import { Entity } from '@backstage/catalog-model'; -import path from 'path'; import fs from 'fs-extra'; +import { Logger } from 'winston'; +import { Entity } from '@backstage/catalog-model'; +import { PublisherBase } from './types'; +import { resolvePackagePath } from '@backstage/backend-common'; export class LocalPublish implements PublisherBase { + private readonly logger: Logger; + + constructor(logger: Logger) { + this.logger = logger; + } + publish({ entity, directory, @@ -30,18 +37,29 @@ export class LocalPublish implements PublisherBase { remoteUrl: string; }> | { remoteUrl: string } { - const publishDir = path.resolve( - __dirname, - `../../../../static/docs/${entity.metadata.name}`, + const entityNamespace = entity.metadata.namespace ?? 'default'; + + const publishDir = resolvePackagePath( + '@backstage/plugin-techdocs-backend', + 'static/docs', + entity.kind, + entityNamespace, + entity.metadata.name, ); if (!fs.existsSync(publishDir)) { + this.logger.info( + `[TechDocs]: Could not find ${publishDir}, creates the directory.`, + ); fs.mkdirSync(publishDir, { recursive: true }); } return new Promise((resolve, reject) => { fs.copy(directory, publishDir, err => { if (err) { + this.logger.debug( + `[TechDocs]: Failed to copy docs from ${directory} to ${publishDir}`, + ); reject(err); } diff --git a/plugins/techdocs/README.md b/plugins/techdocs/README.md index 4a92c814a1..2e9365f6c6 100644 --- a/plugins/techdocs/README.md +++ b/plugins/techdocs/README.md @@ -1,45 +1,15 @@ # TechDocs Plugin -Welcome to the TechDocs plugin - Spotify's docs-like-code approach built directly into [Backstage](https://backstage.io). Watch [a video of our approach on YouTube](https://www.youtube.com/watch?v=uFGCaZmA6d4) to learn more. - -**WIP: This plugin is a work in progress. It is not ready for use yet. Follow our progress on [the Backstage Discord](https://discord.gg/MUpMjP2) under #docs-like-code or on [our GitHub Milestone](https://github.com/spotify/backstage/milestone/15).** - ## Getting started -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/docs](http://localhost:3000/docs). - -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. +Set up Backstage and TechDocs by follow our guide on [Getting Started](../../docs/features/techdocs/getting-started.md). ## Configuration ### Custom Storage URL -TechDocs currently reads a static HTML file, generated by Mkdocs (see our `packages/techdocs-container` folder for more documentation) and stored on an external server, and loads that into Backstage. By default, we have set up a mock server with some example documentation sites over in Google Cloud Storage: +TechDocs will try to read your documentation from the URL you have specified in the `techdocs storageUrl` in `app-config.yml`. -```md -# Base URL +### TechDocs Storage Api -https://techdocs-mock-sites.storage.googleapis.com - -# Home Page for the "mkdocs" docs - -https://techdocs-mock-sites.storage.googleapis.com/mkdocs/index.html - -# Home Page for the "backstage-microsite" docs - -https://techdocs-mock-sites.storage.googleapis.com/backstage-microsite/index.html -``` - -Using your own setup (or ours which is being worked on as of Q3 2020), you can point it to your own server with your own hosted documentation sites. The only requirement is that it the output is from [Mkdocs](https://mkdocs.org) with the Material theme. You can always use our documentation generation tool located at `packages/techdocs-container` for easy setup. - -To point TechDocs to your own server, simply update the `techdocs.storageUrl` value in your `app-config.yaml` file or set the environment variable `APP_CONFIG_techdocs_storageUrl` in your application: - -```bash -git clone git@github.com:spotify/backstage.git -cd backstage/ -yarn install -export APP_CONFIG_techdocs_storageUrl='"http://example-docs-site-server.com"' -yarn start -``` +The default setup of TechDocs assumes your documentation is accessed by reading a page with the format of `///`. If for some reason you want to change this it can be configured by implementing a new techdocs storage API. Do this by implementing TechDocsStorage found in `plugins/techdocs/src/api`. Add your new API to the application in `app/src/apis.ts` (or replace if it's already registered as an API). diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts new file mode 100644 index 0000000000..60a17c3dc3 --- /dev/null +++ b/plugins/techdocs/dev/api.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ParsedEntityId } from '../src/types'; + +import { TechDocsStorage } from '../src/api'; + +export class TechDocsDevStorageApi implements TechDocsStorage { + public apiOrigin: string; + + constructor({ apiOrigin }: { apiOrigin: string }) { + this.apiOrigin = apiOrigin; + } + + async getEntityDocs(entityId: ParsedEntityId, path: string) { + const { name } = entityId; + + const url = `${this.apiOrigin}/${name}/${path}`; + + const request = await fetch( + `${url.endsWith('/') ? url : `${url}/`}index.html`, + ); + + if (request.status === 404) { + throw new Error('Page not found'); + } + + return request.text(); + } + + getBaseUrl( + oldBaseUrl: string, + entityId: ParsedEntityId, + path: string, + ): string { + const { name } = entityId; + return new URL(oldBaseUrl, `${this.apiOrigin}/${name}/${path}`).toString(); + } +} diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx index 812a5585d4..12974ef8b7 100644 --- a/plugins/techdocs/dev/index.tsx +++ b/plugins/techdocs/dev/index.tsx @@ -16,5 +16,17 @@ import { createDevApp } from '@backstage/dev-utils'; import { plugin } from '../src/plugin'; +import { TechDocsDevStorageApi } from './api'; +import { techdocsStorageApiRef } from '../src'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerApi({ + api: techdocsStorageApiRef, + deps: {}, + factory: () => + new TechDocsDevStorageApi({ + apiOrigin: 'http://localhost:3000/api', + }), + }) + .registerPlugin(plugin) + .render(); diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index a1b7ddb589..a3471c8142 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,12 +22,16 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/test-utils": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/catalog-model": "^0.1.1-alpha.21", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/core-api": "^0.1.1-alpha.21", + "@backstage/plugin-catalog": "^0.1.1-alpha.21", + "@backstage/test-utils": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@types/react": "^16.9", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", @@ -36,13 +40,14 @@ "sanitize-html": "^1.27.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", + "canvas": "^2.6.1", "jest-fetch-mock": "^3.0.3" }, "files": [ diff --git a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx b/plugins/techdocs/src/EntityPageDocs.tsx similarity index 69% rename from plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx rename to plugins/techdocs/src/EntityPageDocs.tsx index 469869af69..d10c7a5932 100644 --- a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -15,13 +15,17 @@ */ import React from 'react'; -import { Builds } from '../../pages/BuildsPage/lib/Builds'; import { Entity } from '@backstage/catalog-model'; +import { Reader } from './reader'; -export const JenkinsBuildsWidget = ({ entity }: { entity: Entity }) => { - const [owner, repo] = ( - entity?.metadata.annotations?.['backstage.io/jenkins-github-folder'] ?? '/' - ).split('/'); - - return ; +export const EntityPageDocs = ({ entity }: { entity: Entity }) => { + return ( + + ); }; diff --git a/plugins/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx new file mode 100644 index 0000000000..6dc0dd02ad --- /dev/null +++ b/plugins/techdocs/src/Router.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { Route, Routes } from 'react-router-dom'; +import { WarningPanel } from '@backstage/core'; + +import { + rootRouteRef, + rootDocsRouteRef, + rootCatalogDocsRouteRef, +} from './plugin'; +import { TechDocsHome } from './reader/components/TechDocsHome'; +import { TechDocsPage } from './reader/components/TechDocsPage'; +import { EntityPageDocs } from './EntityPageDocs'; + +const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; + +export const Router = () => { + return ( + + } /> + } /> + + ); +}; + +export const EmbeddedDocsRouter = ({ entity }: { entity: Entity }) => { + const projectId = entity.metadata.annotations?.[TECHDOCS_ANNOTATION]; + + if (!projectId) { + return ( + +
{TECHDOCS_ANNOTATION}
annotation is missing on the entity. +
+ + Getting Started + + + ); + } + + return ( + + } + /> + + ); +}; diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/api.test.ts new file mode 100644 index 0000000000..0ab05d5ecd --- /dev/null +++ b/plugins/techdocs/src/api.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TechDocsStorageApi } from './api'; + +const DOC_STORAGE_URL = 'https://example-storage.com'; + +const mockEntity = { + kind: 'Component', + namespace: 'default', + name: 'test-component', +}; + +describe('TechDocsStorageApi', () => { + it('should return correct base url based on defined storage', () => { + const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); + + expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual( + `${DOC_STORAGE_URL}/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test.js`, + ); + }); + + it('should return base url with correct entity structure', () => { + const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); + + expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual( + `${DOC_STORAGE_URL}/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test/`, + ); + }); +}); diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts new file mode 100644 index 0000000000..6fc913e5df --- /dev/null +++ b/plugins/techdocs/src/api.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; + +import { ParsedEntityId } from './types'; + +export const techdocsStorageApiRef = createApiRef({ + id: 'plugin.techdocs.storageservice', + description: 'Used to make requests towards the techdocs storage', +}); + +export interface TechDocsStorage { + getEntityDocs(entityId: ParsedEntityId, path: string): Promise; + getBaseUrl( + oldBaseUrl: string, + entityId: ParsedEntityId, + path: string, + ): string; +} + +export class TechDocsStorageApi implements TechDocsStorage { + public apiOrigin: string; + + constructor({ apiOrigin }: { apiOrigin: string }) { + this.apiOrigin = apiOrigin; + } + + async getEntityDocs(entityId: ParsedEntityId, path: string) { + const { kind, namespace, name } = entityId; + + const url = `${this.apiOrigin}/${kind}/${ + namespace ? namespace : 'default' + }/${name}/${path}`; + + const request = await fetch( + `${url.endsWith('/') ? url : `${url}/`}index.html`, + ); + + if (request.status === 404) { + throw new Error('Page not found'); + } + + return request.text(); + } + + getBaseUrl( + oldBaseUrl: string, + entityId: ParsedEntityId, + path: string, + ): string { + const { kind, namespace, name } = entityId; + + return new URL( + oldBaseUrl, + `${this.apiOrigin}/${kind}/${ + namespace ? namespace : 'default' + }/${name}/${path}`, + ).toString(); + } +} diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index 3a0a0fe2d3..1726e0daf3 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -15,3 +15,6 @@ */ export { plugin } from './plugin'; +export { Router, EmbeddedDocsRouter } from './Router'; +export * from './reader'; +export * from './api'; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index a96f7e5502..1f15e9ebd6 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -29,24 +29,39 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import { TechDocsHome } from './reader/components/TechDocsHome'; -import { Reader } from './reader/components/Reader'; +import { + createPlugin, + createRouteRef, + createApiFactory, + configApiRef, +} from '@backstage/core'; +import { techdocsStorageApiRef, TechDocsStorageApi } from './api'; export const rootRouteRef = createRouteRef({ - path: '/docs', + path: '', title: 'TechDocs Landing Page', }); export const rootDocsRouteRef = createRouteRef({ - path: '/docs/:componentId/*', + path: ':entityId/*', + title: 'Docs', +}); + +export const rootCatalogDocsRouteRef = createRouteRef({ + path: '*', title: 'Docs', }); export const plugin = createPlugin({ id: 'techdocs', - register({ router }) { - router.addRoute(rootRouteRef, TechDocsHome); - router.addRoute(rootDocsRouteRef, Reader); - }, + apis: [ + createApiFactory({ + api: techdocsStorageApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + new TechDocsStorageApi({ + apiOrigin: configApi.getString('techdocs.requestUrl'), + }), + }), + ], }); diff --git a/plugins/techdocs/src/reader/README.md b/plugins/techdocs/src/reader/README.md index 464090415c..fb9f613651 100644 --- a/plugins/techdocs/src/reader/README.md +++ b/plugins/techdocs/src/reader/README.md @@ -1 +1,20 @@ -# TODO +# TechDocs Reader + +The TechDocs reader is a component that fetches a remote page, runs transformers on it and renders it into a shadow dom. + +Currently there's no easy way to customize which transformers to run or add new ones. If that is needed you would have to fork the techdocs plugin and make your changes in that fork. + +Transformers are functions that optionally takes in parameters from the Reader.tsx component and returns a function which gets passed the DOM of the fetched page. A very simple transformer can look like this. + +```typescript +export const updateH1Text = (): Transformer => { + return dom => { + // Change the first occurance of H1 to say "TechDocs!" + dom.querySelector('h1')?.innerHTML = 'TechDocs!'; + + return dom; + }; +}; +``` + +The transformers are then registered in the Reader.tsx file. They are registered in two places, one place that runs before it's attached to the actual browser DOM (preTransformers) and once after (postTransfomers). Doing modifications is faster before it's attached, but doesn't allow us to do some things, such as attaching event listeners. diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 8e11df428e..8ae3273e12 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -15,12 +15,12 @@ */ import React from 'react'; -import { useApi, configApiRef } from '@backstage/core'; +import { useApi, Progress } from '@backstage/core'; import { useShadowDom } from '..'; import { useAsync } from 'react-use'; -import { AsyncState } from 'react-use/lib/useAsync'; - -import { useLocation, useParams, useNavigate } from 'react-router-dom'; +import { techdocsStorageApiRef } from '../../api'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ParsedEntityId } from '../../types'; import transformer, { addBaseUrl, @@ -31,76 +31,35 @@ import transformer, { onCssReady, sanitizeDOM, } from '../transformers'; -import URLFormatter from '../urlFormatter'; import { TechDocsNotFound } from './TechDocsNotFound'; -import { TechDocsPageWrapper } from './TechDocsPageWrapper'; -const useFetch = (url: string): AsyncState => { - const state = useAsync(async () => { - const request = await fetch(url); - if (request.status === 404) { - return [request.url, new Error('Page not found')]; - } - const response = await request.text(); - return [request.url, response]; - }, [url]); - - const [fetchedUrl, fetchedValue] = state.value ?? []; - - if (url !== fetchedUrl) { - // Fixes a race condition between two pages - return { loading: true }; - } - - return Object.assign(state, fetchedValue ? { value: fetchedValue } : {}); +type Props = { + entityId: ParsedEntityId; }; -const useEnforcedTrailingSlash = (): void => { - React.useEffect(() => { - const actualUrl = window.location.href; - const expectedUrl = new URLFormatter(window.location.href).formatBaseURL(); +export const Reader = ({ entityId }: Props) => { + const { kind, namespace, name } = entityId; + const { '*': path } = useParams(); - if (actualUrl !== expectedUrl) { - window.history.replaceState({}, document.title, expectedUrl); - } - }, []); -}; - -export const Reader = () => { - useEnforcedTrailingSlash(); - - const docStorageUrl = - useApi(configApiRef).getOptionalString('techdocs.storageUrl') ?? - 'https://techdocs-mock-sites.storage.googleapis.com'; - - const location = useLocation(); - const { componentId, '*': path } = useParams(); + const techdocsStorageApi = useApi(techdocsStorageApiRef); const [shadowDomRef, shadowRoot] = useShadowDom(); const navigate = useNavigate(); - const normalizedUrl = new URLFormatter( - `${docStorageUrl}${location.pathname.replace('/docs', '')}`, - ).formatBaseURL(); - const state = useFetch(`${normalizedUrl}index.html`); + + const { value, loading, error } = useAsync(async () => { + return techdocsStorageApi.getEntityDocs({ kind, namespace, name }, path); + }, [techdocsStorageApi, kind, namespace, name, path]); React.useEffect(() => { - if (!shadowRoot) { - return; // Shadow DOM isn't ready - } - - if (state.loading) { - return; // Page isn't ready - } - - if (state.value instanceof Error) { - return; // Docs not found + if (!shadowRoot || loading || error) { + return; // Shadow DOM isn't ready / It's not ready / Docs was not found } // Pre-render - const transformedElement = transformer(state.value as string, [ + const transformedElement = transformer(value as string, [ sanitizeDOM(), addBaseUrl({ - docStorageUrl, - componentId, + techdocsStorageApi, + entityId: entityId, path, }), rewriteDocLinks(), @@ -145,7 +104,7 @@ export const Reader = () => { }, }), onCssReady({ - docStorageUrl, + docStorageUrl: techdocsStorageApi.apiOrigin, onLoading: (dom: Element) => { (dom as HTMLElement).style.setProperty('opacity', '0'); }, @@ -154,15 +113,28 @@ export const Reader = () => { }, }), ]); - }, [componentId, path, shadowRoot, state]); // eslint-disable-line react-hooks/exhaustive-deps + }, [ + name, + path, + shadowRoot, + value, + error, + loading, + namespace, + kind, + entityId, + navigate, + techdocsStorageApi, + ]); - if (state.value instanceof Error) { + if (error) { return ; } return ( - + <> + {loading ? : null}
- + ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx index dd31e6a318..7754f227fc 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx @@ -17,20 +17,34 @@ import { TechDocsHome } from './TechDocsHome'; import React from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; +import { ApiRegistry, ApiProvider } from '@backstage/core-api'; + +import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; describe('TechDocs Home', () => { - it('should render a TechDocs home page', () => { - const { getByTestId, queryByText } = render( - wrapInTestApp(), + const catalogApi: Partial = { + getEntities: () => Promise.resolve([] as Entity[]), + }; + + const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]); + + it('should render a TechDocs home page', async () => { + const { findByTestId, findByText } = render( + wrapInTestApp( + + + , + ), ); // Header - expect(queryByText('Documentation')).toBeInTheDocument(); + expect(await findByText('Documentation')).toBeInTheDocument(); expect( - queryByText(/Documentation available in Backstage/i), + await findByText(/Documentation available in Backstage/i), ).toBeInTheDocument(); // Explore Content - expect(getByTestId('docs-explore')).toBeDefined(); + expect(await findByTestId('docs-explore')).toBeDefined(); }); }); diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index f8f1e89b62..e51de8f57f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -15,59 +15,74 @@ */ import React from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useAsync } from 'react-use'; +import { useNavigate, generatePath } from 'react-router-dom'; import { Grid } from '@material-ui/core'; -import { ItemCard } from '@backstage/core'; +import { ItemCard, Progress, useApi } from '@backstage/core'; import { TechDocsPageWrapper } from './TechDocsPageWrapper'; +import { catalogApiRef } from '@backstage/plugin-catalog'; +import { rootDocsRouteRef } from '../../plugin'; -type DocumentationSite = { - title: string; - description: string; - tags: Array; - path: string; - btnLabel: string; -}; - -const documentationSites: Array = [ - { - title: 'MkDocs', - description: - "MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. ", - tags: ['Developer Tool'], - path: '/docs/mkdocs', - btnLabel: 'Read Docs', - }, - { - title: 'Backstage Docs', - description: 'Main documentation for Backstage features and platform APIs.', - tags: ['Service'], - path: '/docs/backstage', - btnLabel: 'Read Docs', - }, -]; export const TechDocsHome = () => { + const catalogApi = useApi(catalogApiRef); const navigate = useNavigate(); - return ( - <> + const { value, loading, error } = useAsync(async () => { + const entities = await catalogApi.getEntities(); + return entities.filter(entity => { + return !!entity.metadata.annotations?.['backstage.io/techdocs-ref']; + }); + }); + + if (loading) { + return ( - - {documentationSites.map((site: DocumentationSite, index: number) => ( - - navigate(site.path)} - tags={site.tags} - title={site.title} - label={site.btnLabel} - description={site.description} - /> - - ))} - + - + ); + } + + if (error) { + return ( + +

{error.message}

+
+ ); + } + + return ( + + + {value?.length + ? value.map((entity, index: number) => ( + + + navigate( + generatePath(rootDocsRouteRef.path, { + entityId: `${entity.kind}:${ + entity.metadata.namespace ?? '' + }:${entity.metadata.name}`, + }), + ) + } + title={entity.metadata.name} + label="Read Docs" + description={entity.metadata.description} + /> + + )) + : null} + + ); }; diff --git a/plugins/jenkins/src/components/Layout/Layout.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.tsx similarity index 58% rename from plugins/jenkins/src/components/Layout/Layout.tsx rename to plugins/techdocs/src/reader/components/TechDocsPage.tsx index c6000437a3..1328d13ee9 100644 --- a/plugins/jenkins/src/components/Layout/Layout.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.tsx @@ -13,17 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { Header, Page, pageTheme, HeaderLabel } from '@backstage/core'; -export const Layout: React.FC = ({ children }) => { +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { TechDocsPageWrapper } from './TechDocsPageWrapper'; +import { Reader } from './Reader'; + +export const TechDocsPage = () => { + const { entityId } = useParams(); + + const [kind, namespace, name] = entityId.split(':'); + return ( - -
- - -
- {children} -
+ + + ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx index d4cc49c919..5265492fb3 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ import { TechDocsPageWrapper } from './TechDocsPageWrapper'; -import { TechDocsHome } from './TechDocsHome'; import React from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; @@ -24,7 +23,7 @@ describe('TechDocs Page Wrapper', () => { const { queryByText } = render( wrapInTestApp( - + Test , ), ); diff --git a/plugins/circleci/src/components/Layout/index.ts b/plugins/techdocs/src/reader/components/index.ts similarity index 95% rename from plugins/circleci/src/components/Layout/index.ts rename to plugins/techdocs/src/reader/components/index.ts index 236fc98851..b14a6c5b84 100644 --- a/plugins/circleci/src/components/Layout/index.ts +++ b/plugins/techdocs/src/reader/components/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './Layout'; + +export * from './Reader'; diff --git a/plugins/techdocs/src/reader/index.tsx b/plugins/techdocs/src/reader/index.tsx index 72b51bd0dc..a3ca8f394b 100644 --- a/plugins/techdocs/src/reader/index.tsx +++ b/plugins/techdocs/src/reader/index.tsx @@ -15,3 +15,4 @@ */ export * from './hooks'; +export * from './components'; diff --git a/plugins/techdocs/src/reader/transformers/README.md b/plugins/techdocs/src/reader/transformers/README.md deleted file mode 100644 index 464090415c..0000000000 --- a/plugins/techdocs/src/reader/transformers/README.md +++ /dev/null @@ -1 +0,0 @@ -# TODO diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index ce903c5d2f..728c39826d 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -14,124 +14,65 @@ * limitations under the License. */ -import { createTestShadowDom, FIXTURES, getSample } from '../../test-utils'; +import { createTestShadowDom } from '../../test-utils'; import { addBaseUrl } from '../transformers'; +import { TechDocsStorage } from '../../api'; const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com'; +const techdocsStorageApi: TechDocsStorage = { + getBaseUrl: jest.fn(() => DOC_STORAGE_URL), + getEntityDocs: () => new Promise(resolve => resolve('yes!')), +}; + +const fixture = ` + + + + + + + + + +`; + +const mockEntityId = { + kind: '', + namespace: '', + name: '', +}; + describe('addBaseUrl', () => { it('contains relative paths', () => { - const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE); - - expect(getSample(shadowDom, 'img', 'src')).toEqual([ - 'img/win-py-install.png', - 'img/initial-layout.png', - ]); - expect(getSample(shadowDom, 'link', 'href')).toEqual([ - 'https://www.mkdocs.org/', - 'assets/images/favicon.png', - ]); - expect(getSample(shadowDom, 'script', 'src')).toEqual([ - 'https://www.google-analytics.com/analytics.js', - 'assets/javascripts/vendor.d710d30a.min.js', - ]); - }); - - it('contains transformed absolute paths', () => { - const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + createTestShadowDom(fixture, { preTransformers: [ addBaseUrl({ - docStorageUrl: DOC_STORAGE_URL, - componentId: 'example-docs', + techdocsStorageApi, + entityId: mockEntityId, path: '', }), ], postTransformers: [], }); - expect(getSample(shadowDom, 'img', 'src')).toEqual([ - 'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png', - 'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png', - ]); - expect(getSample(shadowDom, 'link', 'href')).toEqual([ - 'https://www.mkdocs.org/', - 'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png', - ]); - expect(getSample(shadowDom, 'script', 'src')).toEqual([ - 'https://www.google-analytics.com/analytics.js', - 'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js', - ]); - }); - - it('includes path option without slash', () => { - const shadowDom = createTestShadowDom( - ` - - - - - - - `, - { - preTransformers: [ - addBaseUrl({ - docStorageUrl: DOC_STORAGE_URL, - componentId: 'example-docs', - path: 'examplepath', - }), - ], - postTransformers: [], - }, + expect(techdocsStorageApi.getBaseUrl).toHaveBeenNthCalledWith( + 1, + 'test.jpg', + mockEntityId, + '', ); - - expect(getSample(shadowDom, 'img', 'src')).toEqual([ - 'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png', - 'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png', - ]); - expect(getSample(shadowDom, 'link', 'href')).toEqual([ - 'https://www.mkdocs.org/', - 'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png', - ]); - expect(getSample(shadowDom, 'script', 'src')).toEqual([ - 'https://www.google-analytics.com/analytics.js', - 'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js', - ]); - }); - - it('includes path option with slash', () => { - const shadowDom = createTestShadowDom( - ` - - - - - - - `, - { - preTransformers: [ - addBaseUrl({ - docStorageUrl: DOC_STORAGE_URL, - componentId: 'example-docs', - path: 'examplepath/', - }), - ], - postTransformers: [], - }, + expect(techdocsStorageApi.getBaseUrl).toHaveBeenNthCalledWith( + 2, + 'script.js', + mockEntityId, + '', + ); + expect(techdocsStorageApi.getBaseUrl).toHaveBeenNthCalledWith( + 3, + 'astyle.css', + mockEntityId, + '', ); - - expect(getSample(shadowDom, 'img', 'src')).toEqual([ - 'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png', - 'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png', - ]); - expect(getSample(shadowDom, 'link', 'href')).toEqual([ - 'https://www.mkdocs.org/', - 'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png', - ]); - expect(getSample(shadowDom, 'script', 'src')).toEqual([ - 'https://www.google-analytics.com/analytics.js', - 'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js', - ]); }); }); diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 42ed531aea..7346ca7ce2 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -14,18 +14,19 @@ * limitations under the License. */ -import URLFormatter from '../urlFormatter'; import type { Transformer } from './index'; +import { TechDocsStorage } from '../../api'; +import { ParsedEntityId } from '../../types'; type AddBaseUrlOptions = { - docStorageUrl: string; - componentId: string; + techdocsStorageApi: TechDocsStorage; + entityId: ParsedEntityId; path: string; }; export const addBaseUrl = ({ - docStorageUrl, - componentId, + techdocsStorageApi, + entityId, path, }: AddBaseUrlOptions): Transformer => { return dom => { @@ -36,15 +37,11 @@ export const addBaseUrl = ({ Array.from(list) .filter(elem => !!elem.getAttribute(attributeName)) .forEach((elem: T) => { - const urlFormatter = new URLFormatter( - path.length < 1 || path.endsWith('/') - ? `${docStorageUrl}/${componentId}/${path}` - : `${docStorageUrl}/${componentId}/${path}/`, - ); - + const elemAttribute = elem.getAttribute(attributeName); + if (!elemAttribute) return; elem.setAttribute( attributeName, - urlFormatter.formatURL(elem.getAttribute(attributeName)!), + techdocsStorageApi.getBaseUrl(elemAttribute, entityId, path), ); }); }; diff --git a/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts index 93e4b67a1b..3566b403a2 100644 --- a/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts +++ b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts @@ -26,9 +26,11 @@ export const addLinkClickListener = ({ return dom => { Array.from(dom.getElementsByTagName('a')).forEach(elem => { elem.addEventListener('click', (e: MouseEvent) => { - e.preventDefault(); const target = e.target as HTMLAnchorElement; - if (target?.getAttribute('href')) { + const href = target?.getAttribute('href'); + if (!href) return; + if (!href.match(/^https?:\/\//i)) { + e.preventDefault(); onClick(e, target.getAttribute('href')!); } }); diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts index 13965bcf4b..c83a94f35d 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts @@ -15,19 +15,23 @@ */ import { - FIXTURES, createTestShadowDom, mockStylesheetEventListener, executeStylesheetEventListeners, clearStylesheetEventListeners, } from '../../test-utils'; -import { addBaseUrl, onCssReady } from '../transformers'; +import { onCssReady } from '../transformers'; const docStorageUrl: string = 'https://techdocs-mock-sites.storage.googleapis.com'; jest.useFakeTimers(); +const fixture = ` + + +`; + describe('onCssReady', () => { beforeEach(() => { mockStylesheetEventListener(100); @@ -37,19 +41,13 @@ describe('onCssReady', () => { clearStylesheetEventListeners(); }); - it('does not call onLoading and onLoaded without the addBaseUrl transformer', () => { + it('does not call onLoading and onLoaded without the onCssReady transformer', () => { const onLoading = jest.fn(); const onLoaded = jest.fn(); - createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + createTestShadowDom(fixture, { preTransformers: [], - postTransformers: [ - onCssReady({ - docStorageUrl, - onLoading, - onLoaded, - }), - ], + postTransformers: [], }); expect(onLoading).not.toHaveBeenCalled(); @@ -61,14 +59,9 @@ describe('onCssReady', () => { const onLoading = jest.fn(); const onLoaded = jest.fn(); - createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + createTestShadowDom(fixture, { preTransformers: [], postTransformers: [ - addBaseUrl({ - docStorageUrl, - componentId: 'mkdocs', - path: '', - }), onCssReady({ docStorageUrl, onLoading, diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts index 20ec3ccb64..06da8a447c 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts @@ -50,9 +50,9 @@ describe('rewriteDocLinks', () => { expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([ 'http://example.org/', - 'http://localhost/example/', - 'http://localhost/example-docs/', - 'http://localhost/example-docs/example-page/', + 'http://localhost/example', + 'http://localhost/example-docs', + 'http://localhost/example-docs/example-page', ]); }); }); diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts index 4bf4ab3898..7b0c23c3e3 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import URLFormatter from '../urlFormatter'; import type { Transformer } from './index'; export const rewriteDocLinks = (): Transformer => { @@ -26,11 +25,21 @@ export const rewriteDocLinks = (): Transformer => { Array.from(list) .filter(elem => elem.hasAttribute(attributeName)) .forEach((elem: T) => { - const urlFormatter = new URLFormatter(window.location.href); - elem.setAttribute( - attributeName, - urlFormatter.formatURL(elem.getAttribute(attributeName)!), - ); + const elemAttribute = elem.getAttribute(attributeName); + if (elemAttribute) { + // if link is external, add target to open in a new window or tab + if (elemAttribute.match(/^https?:\/\//i)) { + elem.setAttribute('target', '_blank'); + } + const normalizedWindowLocation = window.location.href.endsWith('/') + ? window.location.href + : `${window.location.href}/`; + + elem.setAttribute( + attributeName, + new URL(elemAttribute, normalizedWindowLocation).toString(), + ); + } }); }; diff --git a/plugins/techdocs/src/reader/urlFormatter.test.ts b/plugins/techdocs/src/reader/urlFormatter.test.ts deleted file mode 100644 index 2659f9f4dc..0000000000 --- a/plugins/techdocs/src/reader/urlFormatter.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import URLFormatter from './urlFormatter'; - -describe('URLFormatter', () => { - describe('formatURL', () => { - it('should not change an absolute url', () => { - const formatter = new URLFormatter('https://www.google.com/'); - expect(formatter.formatURL('https://www.mkdocs.org/')).toEqual( - 'https://www.mkdocs.org/', - ); - }); - - it('should convert a relative url to an absolute url', () => { - const formatter = new URLFormatter( - 'https://www.mkdocs.org/user-guide/getting-started/', - ); - expect(formatter.formatURL('../../support/installing/')).toEqual( - 'https://www.mkdocs.org/support/installing/', - ); - }); - - it('should add a trailing slash', () => { - const formatter = new URLFormatter( - 'https://www.mkdocs.org/user-guide/getting-started', - ); - expect(formatter.formatURL('./getting-started')).toEqual( - 'https://www.mkdocs.org/user-guide/getting-started/', - ); - }); - - it('should not add a trailing slash', () => { - const formatter = new URLFormatter( - 'https://www.mkdocs.org/user-guide/getting-started/', - ); - expect(formatter.formatURL('.')).toEqual( - 'https://www.mkdocs.org/user-guide/getting-started/', - ); - }); - - it('should not add multiple hashes', () => { - const formatter = new URLFormatter( - 'https://www.mkdocs.org/user-guide/getting-started/#hash1', - ); - expect(formatter.formatURL('./#hash2')).toEqual( - 'https://www.mkdocs.org/user-guide/getting-started/#hash2', - ); - }); - }); - - describe('formatBaseURL', () => { - it('should keep query params in URL', () => { - const formatter = new URLFormatter( - 'https://www.mkdocs.org/user-guide/getting-started/?query=hello+world', - ); - expect(formatter.formatBaseURL()).toEqual( - 'https://www.mkdocs.org/user-guide/getting-started/?query=hello+world', - ); - }); - - it('should keep hash in URL', () => { - const formatter = new URLFormatter( - 'https://www.mkdocs.org/user-guide/getting-started/#hash', - ); - expect(formatter.formatBaseURL()).toEqual( - 'https://www.mkdocs.org/user-guide/getting-started/#hash', - ); - }); - }); -}); diff --git a/plugins/techdocs/src/reader/urlFormatter.ts b/plugins/techdocs/src/reader/urlFormatter.ts deleted file mode 100644 index 166f0d9963..0000000000 --- a/plugins/techdocs/src/reader/urlFormatter.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export default class URLFormatter { - constructor(public baseURL: string) {} - - formatBaseURL(): string { - return this.normalizeURL(this.baseURL); - } - - formatURL(pathname: string): string { - return this.normalizeURL(new URL(pathname, this.baseURL).toString()); - } - - private normalizeURL(urlString: string): string { - const url = new URL(urlString); - const filename: string = url.pathname.split('/').pop() ?? url.pathname; - const isDir: boolean = filename.includes('.') === false; - - if (isDir) { - url.pathname = url.pathname.replace(/([^/])$/, '$1/'); - } - - return url.toString(); - } -} diff --git a/plugins/techdocs/src/types.ts b/plugins/techdocs/src/types.ts new file mode 100644 index 0000000000..ec907cc6fa --- /dev/null +++ b/plugins/techdocs/src/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type ParsedEntityId = { + kind: string; + namespace?: string; + name: string; +}; diff --git a/plugins/welcome/README.md b/plugins/welcome/README.md index 95d8947824..f0211c248a 100644 --- a/plugins/welcome/README.md +++ b/plugins/welcome/README.md @@ -1,4 +1,5 @@ # Title + Welcome to the welcome plugin! ## Sub-section 1 diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index 2c0c838de8..a0dae6859d 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-welcome", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.21", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -21,9 +21,9 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.18", - "@backstage/theme": "^0.1.1-alpha.18", - "@material-ui/core": "^4.9.1", + "@backstage/core": "^0.1.1-alpha.21", + "@backstage/theme": "^0.1.1-alpha.21", + "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", @@ -32,8 +32,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", - "@backstage/dev-utils": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/dev-utils": "^0.1.1-alpha.21", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/tsconfig.json b/tsconfig.json index 1a6128b5c9..d6cb6f4c96 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "plugins/*/migrations" ], "compilerOptions": { - "outDir": "dist" + "outDir": "dist-types", + "rootDir": "." } } diff --git a/yarn.lock b/yarn.lock index e15dd3bed6..1e1de1c69d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,15 @@ # yarn lockfile v1 +"@apidevtools/json-schema-ref-parser@9.0.6", "@apidevtools/json-schema-ref-parser@^9.0.6": + version "9.0.6" + resolved "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz#5d9000a3ac1fd25404da886da6b266adcd99cf1c" + integrity sha512-M3YgsLjI0lZxvrpeGVk9Ap032W6TPQkH6pRAZz81Ac3WUNF79VQooAFnp8umjvVzUmD93NkogxEwbSce7qMsUg== + dependencies: + "@jsdevtools/ono" "^7.1.3" + call-me-maybe "^1.0.1" + js-yaml "^3.13.1" + "@apollo/protobufjs@^1.0.3": version "1.0.4" resolved "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.0.4.tgz#cf01747a55359066341f31b5ce8db17df44244e0" @@ -40,18 +49,45 @@ resolved "https://registry.npmjs.org/@ardatan/aggregate-error/-/aggregate-error-0.0.1.tgz#1403ac5de10d8ca689fc1f65844c27179ae1d44f" integrity sha512-UQ9BequOTIavs0pTHLMwQwKQF8tTV1oezY/H2O9chA+JNPFZSua55xpU5dPSjAU9/jLJ1VwU+HJuTVN8u7S6Fg== -"@asyncapi/parser@0.16.2": - version "0.16.2" - resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-0.16.2.tgz#6e0806d0a751e999499212e52926a6dc6ec251fe" - integrity sha512-dagGD6VFAgl/zLQuoWAfi48i74wv32naV6WcvazcpvEXshW3Q//OrUyRFa6EbY0lmIMXeasc7HKm40AUJ920ug== +"@asyncapi/avro-schema-parser@^0.1.2": + version "0.1.2" + resolved "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-0.1.2.tgz#f6c340ccaa24bc36399d3a0f1a6e02790a948453" + integrity sha512-K4GlakiE42J9AWwAu3BWn3Qbf+N8C6vE4eEm5LEx7HqAhHG+FA0U7/3vvXoj31rnS/8sgPQpc2msLWQlmCtZyw== + +"@asyncapi/openapi-schema-parser@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@asyncapi/openapi-schema-parser/-/openapi-schema-parser-2.0.0.tgz#80e2f38e92b6635dde19aae07b92e3caa0effc58" + integrity sha512-XfDp3EIs6ptar3jARQZzi3ObmS44l6Qozc5GJmZJUQ6mHLTTqUGJ0nxcrXAW88vosjilgJVaQ63oGolA6smSHQ== dependencies: + "@openapi-contrib/openapi-schema-to-json-schema" "^3.0.0" + +"@asyncapi/parser@^1.0.0-rc.1": + version "1.0.0-rc.2" + resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.0.0-rc.2.tgz#87d2e83c1d390e21d53f868e6209cbc584d35449" + integrity sha512-nZYJLnMiq48q7YHa+AI9ZaDc5UqoqKR7MDhTiIKsIJj6X5fF6hpwBrTP0bQn76Pc1oGBXRB69PcD/Y+Et1qFyw== + dependencies: + "@apidevtools/json-schema-ref-parser" "^9.0.6" + "@asyncapi/specs" "^2.7.4" + "@fmvilas/pseudo-yaml-ast" "^0.3.1" ajv "^6.10.1" - asyncapi "^2.6.1" js-yaml "^3.13.1" - json-schema-ref-parser "^7.1.0" + json-to-ast "^2.1.0" node-fetch "^2.6.0" tiny-merge-patch "^0.1.2" +"@asyncapi/raml-dt-schema-parser@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@asyncapi/raml-dt-schema-parser/-/raml-dt-schema-parser-2.0.0.tgz#6e9eff89032aa7b82a963d8e72e320e493fc3835" + integrity sha512-ve41LIvbqDU8s0ZeDrWy+dyQZ/XSb7vKiJUHNgy8xcgxsz4YuttbEJbyGJvarw3qf2y+0y6cA1KUFiXoE+AIUg== + dependencies: + js-yaml "^3.13.1" + ramldt2jsonschema "^1.1.0" + +"@asyncapi/specs@^2.7.4": + version "2.7.4" + resolved "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.7.4.tgz#5db4390b68aeb40d70c1723d8f46b0a091110afc" + integrity sha512-3Np9ip1Qn5AgnxTrbI0CMW2F/WUorpiAKz+GUfEy4a6GPk0eTSI6CIcbx2/jej5P91nhEyny9+D3oIzn2MreTA== + "@babel/code-frame@7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" @@ -59,37 +95,21 @@ dependencies: "@babel/highlight" "^7.0.0" -"@babel/code-frame@7.5.5": - version "7.5.5" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d" - integrity sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw== - dependencies: - "@babel/highlight" "^7.0.0" - -"@babel/code-frame@7.8.3", "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.5.5", "@babel/code-frame@^7.8.3": +"@babel/code-frame@7.8.3": version "7.8.3" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e" integrity sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g== dependencies: "@babel/highlight" "^7.8.3" -"@babel/code-frame@^7.10.4": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.5.5": version "7.10.4" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== dependencies: "@babel/highlight" "^7.10.4" -"@babel/compat-data@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.10.5.tgz#d38425e67ea96b1480a3f50404d1bf85676301a6" - integrity sha512-mPVoWNzIpYJHbWje0if7Ck36bpbtTvIxOi9+6WSK9wjGEXearAqlwBoTQvVjsAY2VIwgcs8V940geY3okzRCEw== - dependencies: - browserslist "^4.12.0" - invariant "^2.2.4" - semver "^5.5.0" - -"@babel/compat-data@^7.11.0": +"@babel/compat-data@^7.10.4", "@babel/compat-data@^7.11.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.11.0.tgz#e9f73efe09af1355b723a7f39b11bad637d7c99c" integrity sha512-TPSvJfv73ng0pfnEOh17bYMPQbI95+nGWc71Ss4vZdRBHTDqmM9Z8ZV4rYz8Ks7sfzc95n30k6ODIq5UGnXcYQ== @@ -98,7 +118,7 @@ invariant "^2.2.4" semver "^5.5.0" -"@babel/core@^7.0.0", "@babel/core@^7.9.0": +"@babel/core@^7.0.0", "@babel/core@^7.1.0", "@babel/core@^7.4.4", "@babel/core@^7.7.5", "@babel/core@^7.9.0": version "7.11.1" resolved "https://registry.npmjs.org/@babel/core/-/core-7.11.1.tgz#2c55b604e73a40dc21b0e52650b11c65cf276643" integrity sha512-XqF7F6FWQdKGGWAzGELL+aCO1p+lRY5Tj5/tbT3St1G8NaH70jhhDIKknIZaDans0OQBG5wRAldROLHSt44BgQ== @@ -120,28 +140,6 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/core@^7.1.0", "@babel/core@^7.4.4", "@babel/core@^7.4.5", "@babel/core@^7.7.5": - version "7.9.6" - resolved "https://registry.npmjs.org/@babel/core/-/core-7.9.6.tgz#d9aa1f580abf3b2286ef40b6904d390904c63376" - integrity sha512-nD3deLvbsApbHAHttzIssYqgb883yU/d9roe4RZymBCDaZryMJDbptVpEpeQuRh4BJ+SYI8le9YGxKvFEvl1Wg== - dependencies: - "@babel/code-frame" "^7.8.3" - "@babel/generator" "^7.9.6" - "@babel/helper-module-transforms" "^7.9.0" - "@babel/helpers" "^7.9.6" - "@babel/parser" "^7.9.6" - "@babel/template" "^7.8.6" - "@babel/traverse" "^7.9.6" - "@babel/types" "^7.9.6" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.1" - json5 "^2.1.2" - lodash "^4.17.13" - resolve "^1.3.2" - semver "^5.4.1" - source-map "^0.5.0" - "@babel/generator@^7.10.5": version "7.10.5" resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.10.5.tgz#1b903554bc8c583ee8d25f1e8969732e6b829a69" @@ -160,16 +158,6 @@ jsesc "^2.5.1" source-map "^0.5.0" -"@babel/generator@^7.9.6": - version "7.9.6" - resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.9.6.tgz#5408c82ac5de98cda0d77d8124e99fa1f2170a43" - integrity sha512-+htwWKJbH2bL72HRluF8zumBxzuX0ZZUFl3JLNyoUjM/Ho8wnVpPXM6aUz8cfKDqQ/h7zHqKt4xzJteUosckqQ== - dependencies: - "@babel/types" "^7.9.6" - jsesc "^2.5.1" - lodash "^4.17.13" - source-map "^0.5.0" - "@babel/helper-annotate-as-pure@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3" @@ -177,13 +165,6 @@ dependencies: "@babel/types" "^7.10.4" -"@babel/helper-annotate-as-pure@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.8.3.tgz#60bc0bc657f63a0924ff9a4b4a0b24a13cf4deee" - integrity sha512-6o+mJrZBxOoEX77Ezv9zwW7WV8DdluouRKNY/IR5u/YTMuKHgugHOzYWlYvYLpLA9nPsQCAAASpCIbjI9Mv+Uw== - dependencies: - "@babel/types" "^7.8.3" - "@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.10.4.tgz#bb0b75f31bf98cbf9ff143c1ae578b87274ae1a3" @@ -220,7 +201,7 @@ levenary "^1.1.1" semver "^5.5.0" -"@babel/helper-create-class-features-plugin@^7.10.4": +"@babel/helper-create-class-features-plugin@^7.10.4", "@babel/helper-create-class-features-plugin@^7.10.5": version "7.10.5" resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.5.tgz#9f61446ba80e8240b0a5c85c6fdac8459d6f259d" integrity sha512-0nkdeijB7VlZoLT3r/mY3bUkw3T8WG/hNw+FATs/6+pG2039IJWjTYL0VTISqsNHMUTEnwbVnc89WIJX9Qed0A== @@ -232,18 +213,6 @@ "@babel/helper-replace-supers" "^7.10.4" "@babel/helper-split-export-declaration" "^7.10.4" -"@babel/helper-create-class-features-plugin@^7.8.3": - version "7.8.6" - resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.8.6.tgz#243a5b46e2f8f0f674dc1387631eb6b28b851de0" - integrity sha512-klTBDdsr+VFFqaDHm5rR69OpEQtO2Qv8ECxHS1mNhJJvaHArR6a1xTf5K/eZW7eZpJbhCx3NW1Yt/sKsLXLblg== - dependencies: - "@babel/helper-function-name" "^7.8.3" - "@babel/helper-member-expression-to-functions" "^7.8.3" - "@babel/helper-optimise-call-expression" "^7.8.3" - "@babel/helper-plugin-utils" "^7.8.3" - "@babel/helper-replace-supers" "^7.8.6" - "@babel/helper-split-export-declaration" "^7.8.3" - "@babel/helper-create-regexp-features-plugin@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.10.4.tgz#fdd60d88524659a0b6959c0579925e425714f3b8" @@ -253,15 +222,6 @@ "@babel/helper-regex" "^7.10.4" regexpu-core "^4.7.0" -"@babel/helper-create-regexp-features-plugin@^7.8.3", "@babel/helper-create-regexp-features-plugin@^7.8.8": - version "7.8.8" - resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.8.8.tgz#5d84180b588f560b7864efaeea89243e58312087" - integrity sha512-LYVPdwkrQEiX9+1R29Ld/wTrmQu1SSKYnuOk3g0CkcZMA1p0gsNxJFj/3gBdaJ7Cg0Fnek5z0DsMULePP7Lrqg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.8.3" - "@babel/helper-regex" "^7.8.3" - regexpu-core "^4.7.0" - "@babel/helper-define-map@^7.10.4": version "7.10.5" resolved "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.10.5.tgz#b53c10db78a640800152692b13393147acb9bb30" @@ -288,15 +248,6 @@ "@babel/template" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-function-name@^7.8.3", "@babel/helper-function-name@^7.9.5": - version "7.9.5" - resolved "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.9.5.tgz#2b53820d35275120e1874a82e5aabe1376920a5c" - integrity sha512-JVcQZeXM59Cd1qanDUxv9fgJpt3NeKUaqBqUEvfmQ+BCOKq2xUgaWZW2hr0dkbyJgezYuplEoh5knmrnS68efw== - dependencies: - "@babel/helper-get-function-arity" "^7.8.3" - "@babel/template" "^7.8.3" - "@babel/types" "^7.9.5" - "@babel/helper-get-function-arity@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2" @@ -304,13 +255,6 @@ dependencies: "@babel/types" "^7.10.4" -"@babel/helper-get-function-arity@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz#b894b947bd004381ce63ea1db9f08547e920abd5" - integrity sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA== - dependencies: - "@babel/types" "^7.8.3" - "@babel/helper-hoist-variables@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e" @@ -325,41 +269,14 @@ dependencies: "@babel/types" "^7.10.5" -"@babel/helper-member-expression-to-functions@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz#659b710498ea6c1d9907e0c73f206eee7dadc24c" - integrity sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA== - dependencies: - "@babel/types" "^7.8.3" - -"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz#7fe39589b39c016331b6b8c3f441e8f0b1419498" - integrity sha512-R0Bx3jippsbAEtzkpZ/6FIiuzOURPcMjHp+Z6xPe6DtApDJx+w7UYyOLanZqO8+wKR9G10s/FmHXvxaMd9s6Kg== - dependencies: - "@babel/types" "^7.8.3" - -"@babel/helper-module-imports@^7.10.4": +"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620" integrity sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw== dependencies: "@babel/types" "^7.10.4" -"@babel/helper-module-transforms@^7.10.4", "@babel/helper-module-transforms@^7.10.5": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.10.5.tgz#120c271c0b3353673fcdfd8c053db3c544a260d6" - integrity sha512-4P+CWMJ6/j1W915ITJaUkadLObmCRRSC234uctJfn/vHrsLNxsR8dwlcXv9ZhJWzl77awf+mWXSZEKt5t0OnlA== - dependencies: - "@babel/helper-module-imports" "^7.10.4" - "@babel/helper-replace-supers" "^7.10.4" - "@babel/helper-simple-access" "^7.10.4" - "@babel/helper-split-export-declaration" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/types" "^7.10.5" - lodash "^4.17.19" - -"@babel/helper-module-transforms@^7.11.0": +"@babel/helper-module-transforms@^7.10.4", "@babel/helper-module-transforms@^7.10.5", "@babel/helper-module-transforms@^7.11.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz#b16f250229e47211abdd84b34b64737c2ab2d359" integrity sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg== @@ -372,19 +289,6 @@ "@babel/types" "^7.11.0" lodash "^4.17.19" -"@babel/helper-module-transforms@^7.9.0": - version "7.9.0" - resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.9.0.tgz#43b34dfe15961918707d247327431388e9fe96e5" - integrity sha512-0FvKyu0gpPfIQ8EkxlrAydOWROdHpBmiCiRwLkUiBGhCUPRRbVD2/tm3sFr/c/GWFrQ/ffutGUAnx7V0FzT2wA== - dependencies: - "@babel/helper-module-imports" "^7.8.3" - "@babel/helper-replace-supers" "^7.8.6" - "@babel/helper-simple-access" "^7.8.3" - "@babel/helper-split-export-declaration" "^7.8.3" - "@babel/template" "^7.8.6" - "@babel/types" "^7.9.0" - lodash "^4.17.13" - "@babel/helper-optimise-call-expression@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673" @@ -392,19 +296,7 @@ dependencies: "@babel/types" "^7.10.4" -"@babel/helper-optimise-call-expression@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz#7ed071813d09c75298ef4f208956006b6111ecb9" - integrity sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ== - dependencies: - "@babel/types" "^7.8.3" - -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670" - integrity sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ== - -"@babel/helper-plugin-utils@^7.10.4": +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== @@ -416,13 +308,6 @@ dependencies: lodash "^4.17.19" -"@babel/helper-regex@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.8.3.tgz#139772607d51b93f23effe72105b319d2a4c6965" - integrity sha512-BWt0QtYv/cg/NecOAZMdcn/waj/5P26DR4mVLXfFtDokSR6fyuG0Pj+e2FqtSME+MqED1khnSMulkmGl8qWiUQ== - dependencies: - lodash "^4.17.13" - "@babel/helper-remap-async-to-generator@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.10.4.tgz#fce8bea4e9690bbe923056ded21e54b4e8b68ed5" @@ -444,16 +329,6 @@ "@babel/traverse" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-replace-supers@^7.8.6": - version "7.8.6" - resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.8.6.tgz#5ada744fd5ad73203bf1d67459a27dcba67effc8" - integrity sha512-PeMArdA4Sv/Wf4zXwBKPqVj7n9UF/xg6slNRtZW84FM7JpE1CbG8B612FyM4cxrf4fMAMGO0kR7voy1ForHHFA== - dependencies: - "@babel/helper-member-expression-to-functions" "^7.8.3" - "@babel/helper-optimise-call-expression" "^7.8.3" - "@babel/traverse" "^7.8.6" - "@babel/types" "^7.8.6" - "@babel/helper-simple-access@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461" @@ -462,14 +337,6 @@ "@babel/template" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-simple-access@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz#7f8109928b4dab4654076986af575231deb639ae" - integrity sha512-VNGUDjx5cCWg4vvCTR8qQ7YJYZ+HBjxOgXEl7ounz+4Sn7+LMD3CFrCTEU6/qXKbA2nKg21CwhhBzO0RpRbdCw== - dependencies: - "@babel/template" "^7.8.3" - "@babel/types" "^7.8.3" - "@babel/helper-skip-transparent-expression-wrappers@^7.11.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.11.0.tgz#eec162f112c2f58d3af0af125e3bb57665146729" @@ -477,37 +344,18 @@ dependencies: "@babel/types" "^7.11.0" -"@babel/helper-split-export-declaration@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.4.tgz#2c70576eaa3b5609b24cb99db2888cc3fc4251d1" - integrity sha512-pySBTeoUff56fL5CBU2hWm9TesA4r/rOkI9DyJLvvgz09MB9YtfIYe3iBriVaYNaPe+Alua0vBIOVOLs2buWhg== - dependencies: - "@babel/types" "^7.10.4" - -"@babel/helper-split-export-declaration@^7.11.0": +"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f" integrity sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg== dependencies: "@babel/types" "^7.11.0" -"@babel/helper-split-export-declaration@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz#31a9f30070f91368a7182cf05f831781065fc7a9" - integrity sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA== - dependencies: - "@babel/types" "^7.8.3" - "@babel/helper-validator-identifier@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== -"@babel/helper-validator-identifier@^7.9.0", "@babel/helper-validator-identifier@^7.9.5": - version "7.9.5" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz#90977a8e6fbf6b431a7dc31752eee233bf052d80" - integrity sha512-/8arLKUFq882w4tWGj9JYzRpAlZgiWUJ+dtteNTDqrRBz9Iguck9Rn3ykuBDoUwh2TO4tSAJlrxDUOXWklJe4g== - "@babel/helper-wrap-function@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87" @@ -527,25 +375,7 @@ "@babel/traverse" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helpers@^7.9.6": - version "7.9.6" - resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.9.6.tgz#092c774743471d0bb6c7de3ad465ab3d3486d580" - integrity sha512-tI4bUbldloLcHWoRUMAj4g1bF313M/o6fBKhIsb3QnGVPwRm9JsNf/gqMkQ7zjqReABiffPV6RWj7hEglID5Iw== - dependencies: - "@babel/template" "^7.8.3" - "@babel/traverse" "^7.9.6" - "@babel/types" "^7.9.6" - -"@babel/highlight@^7.0.0", "@babel/highlight@^7.8.3": - version "7.9.0" - resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz#4e9b45ccb82b79607271b2979ad82c7b68163079" - integrity sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ== - dependencies: - "@babel/helper-validator-identifier" "^7.9.0" - chalk "^2.0.0" - js-tokens "^4.0.0" - -"@babel/highlight@^7.10.4": +"@babel/highlight@^7.0.0", "@babel/highlight@^7.10.4", "@babel/highlight@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143" integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA== @@ -554,7 +384,7 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@7.10.5", "@babel/parser@^7.10.4", "@babel/parser@^7.10.5": +"@babel/parser@7.10.5", "@babel/parser@^7.10.5": version "7.10.5" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.10.5.tgz#e7c6bf5a7deff957cec9f04b551e2762909d826b" integrity sha512-wfryxy4bE1UivvQKSQDU4/X6dr+i8bctjUjj8Zyt3DQy7NtPizJXT8M52nqpNKL+nq2PW8lxk4ZqLj0fD4B4hQ== @@ -564,12 +394,7 @@ resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.1.tgz#d91a387990b21e5d20047b336bb19b0553f02ff5" integrity sha512-u9QMIRdKVF7hfEkb3nu2LgZDIzCQPv+yHD9Eg6ruoJLjkrQ9fFz4IBSlF/9XwoNri9+2F1IY+dYuOfZrXq8t3w== -"@babel/parser@^7.1.0", "@babel/parser@^7.7.5", "@babel/parser@^7.8.6", "@babel/parser@^7.9.6": - version "7.9.6" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.9.6.tgz#3b1bbb30dabe600cd72db58720998376ff653bc7" - integrity sha512-AoeIEJn8vt+d/6+PXDRPaksYhnlbMIiejioBZvvMQsOjW/JYK6k/0dKnvvP3EhK5GfMBWDPtrxRtegWdAcdq9Q== - -"@babel/parser@^7.11.0", "@babel/parser@^7.11.1": +"@babel/parser@^7.1.0", "@babel/parser@^7.10.4", "@babel/parser@^7.11.0", "@babel/parser@^7.11.1": version "7.11.3" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.3.tgz#9e1eae46738bcd08e23e867bab43e7b95299a8f9" integrity sha512-REo8xv7+sDxkKvoxEywIdsNFiZLybwdI7hcT5uEPyQrSMB4YQ973BfC9OOrD/81MaIjh6UxdulIQXkjmiH3PcA== @@ -591,13 +416,14 @@ "@babel/helper-create-class-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-class-properties@^7.7.0": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.8.3.tgz#5e06654af5cd04b608915aada9b2a6788004464e" - integrity sha512-EqFhbo7IosdgPgZggHaNObkmO1kNUe3slaKu54d5OWvy+p9QIKOzK1GAEpAIsZtWVtPXUHSMcT4smvDrCfY4AA== +"@babel/plugin-proposal-decorators@^7.8.3": + version "7.10.5" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.10.5.tgz#42898bba478bc4b1ae242a703a953a7ad350ffb4" + integrity sha512-Sc5TAQSZuLzgY0664mMDn24Vw2P8g/VhyLyGPaWiHahhgLqeZvcGeyBZOrJW0oSKIK2mvQ22a1ENXBIQLhrEiQ== dependencies: - "@babel/helper-create-class-features-plugin" "^7.8.3" - "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-create-class-features-plugin" "^7.10.5" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-decorators" "^7.10.4" "@babel/plugin-proposal-dynamic-import@^7.10.4": version "7.10.4" @@ -607,6 +433,14 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-dynamic-import" "^7.8.0" +"@babel/plugin-proposal-export-default-from@^7.8.3": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.10.4.tgz#08f66eef0067cbf6a7bc036977dcdccecaf0c6c5" + integrity sha512-G1l00VvDZ7Yk2yRlC5D8Ybvu3gmeHS3rCHoUYdjrqGYUtdeOBoRypnvDZ5KQqxyaiiGHWnVDeSEzA5F9ozItig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-export-default-from" "^7.10.4" + "@babel/plugin-proposal-export-namespace-from@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.10.4.tgz#570d883b91031637b3e2958eea3c438e62c05f54" @@ -631,7 +465,7 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" -"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4": +"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.1", "@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz#02a7e961fc32e6d5b2db0649e01bf80ddee7e04a" integrity sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw== @@ -647,7 +481,7 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-numeric-separator" "^7.10.4" -"@babel/plugin-proposal-object-rest-spread@^7.0.0", "@babel/plugin-proposal-object-rest-spread@^7.11.0", "@babel/plugin-proposal-object-rest-spread@^7.9.0": +"@babel/plugin-proposal-object-rest-spread@^7.0.0", "@babel/plugin-proposal-object-rest-spread@^7.11.0", "@babel/plugin-proposal-object-rest-spread@^7.9.6": version "7.11.0" resolved "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.11.0.tgz#bd81f95a1f746760ea43b6c2d3d62b11790ad0af" integrity sha512-wzch41N4yztwoRw0ak+37wxwJM2oiIiy6huGCoqkvSTA9acYWcPfn9Y4aJqmFFJ70KTJUu29f3DQ43uJ9HXzEA== @@ -656,23 +490,6 @@ "@babel/plugin-syntax-object-rest-spread" "^7.8.0" "@babel/plugin-transform-parameters" "^7.10.4" -"@babel/plugin-proposal-object-rest-spread@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.10.4.tgz#50129ac216b9a6a55b3853fdd923e74bf553a4c0" - integrity sha512-6vh4SqRuLLarjgeOf4EaROJAHjvu9Gl+/346PbDH9yWbJyfnJ/ah3jmYKYtswEyCoWZiidvVHjHshd4WgjB9BA== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-object-rest-spread" "^7.8.0" - "@babel/plugin-transform-parameters" "^7.10.4" - -"@babel/plugin-proposal-object-rest-spread@^7.6.2": - version "7.9.0" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.9.0.tgz#a28993699fc13df165995362693962ba6b061d6f" - integrity sha512-UgqBv6bjq4fDb8uku9f+wcm1J7YxJ5nT7WO/jBr0cl0PLKb7t1O6RNR1kZbjgx2LQtsDI9hwoQVmn0yhXeQyow== - dependencies: - "@babel/helper-plugin-utils" "^7.8.3" - "@babel/plugin-syntax-object-rest-spread" "^7.8.0" - "@babel/plugin-proposal-optional-catch-binding@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.10.4.tgz#31c938309d24a78a49d68fdabffaa863758554dd" @@ -681,15 +498,7 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" -"@babel/plugin-proposal-optional-chaining@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.10.4.tgz#750f1255e930a1f82d8cdde45031f81a0d0adff7" - integrity sha512-ZIhQIEeavTgouyMSdZRap4VPPHqJJ3NEs2cuHs5p0erH+iz6khB0qfgU8g7UuJkG88+fBMy23ZiU+nuHvekJeQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-optional-chaining" "^7.8.0" - -"@babel/plugin-proposal-optional-chaining@^7.11.0": +"@babel/plugin-proposal-optional-chaining@^7.10.1", "@babel/plugin-proposal-optional-chaining@^7.11.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.11.0.tgz#de5866d0646f6afdaab8a566382fe3a221755076" integrity sha512-v9fZIu3Y8562RRwhm1BbMRxtqZNFmFA2EG+pT2diuU8PT3H6T/KXoZ54KgYisfOFZHV6PfvAiBIZ9Rcz+/JCxA== @@ -698,7 +507,7 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" "@babel/plugin-syntax-optional-chaining" "^7.8.0" -"@babel/plugin-proposal-private-methods@^7.10.4": +"@babel/plugin-proposal-private-methods@^7.10.4", "@babel/plugin-proposal-private-methods@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.10.4.tgz#b160d972b8fdba5c7d111a145fc8c421fc2a6909" integrity sha512-wh5GJleuI8k3emgTg5KkJK6kHNsGEr0uBTDBuQUBJwckk9xs1ez79ioheEVVxMLyPscB0LfkbVHslQqIzWV6Bw== @@ -706,7 +515,7 @@ "@babel/helper-create-class-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-unicode-property-regex@^7.10.4": +"@babel/plugin-proposal-unicode-property-regex@^7.10.4", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.10.4.tgz#4483cda53041ce3413b7fe2f00022665ddfaa75d" integrity sha512-H+3fOgPnEXFL9zGYtKQe4IDOPKYlZdF1kqFDQRRb8PK4B8af1vAGK04tF5iQAAsui+mHNBQSAtd2/ndEDe9wuA== @@ -714,14 +523,6 @@ "@babel/helper-create-regexp-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-unicode-property-regex@^7.4.4": - version "7.8.8" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.8.8.tgz#ee3a95e90cdc04fe8cd92ec3279fa017d68a0d1d" - integrity sha512-EVhjVsMpbhLw9ZfHWSx2iy13Q8Z/eg8e8ccVWt23sWQK5l1UdkoLJPN5w69UA4uITGBnEZD2JOe4QOHycYKv8A== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.8.8" - "@babel/helper-plugin-utils" "^7.8.3" - "@babel/plugin-syntax-async-generators@^7.8.0", "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" @@ -736,27 +537,34 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-class-properties@^7.0.0", "@babel/plugin-syntax-class-properties@^7.10.4": +"@babel/plugin-syntax-class-properties@^7.0.0", "@babel/plugin-syntax-class-properties@^7.10.4", "@babel/plugin-syntax-class-properties@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.4.tgz#6644e6a0baa55a61f9e3231f6c9eeb6ee46c124c" integrity sha512-GCSBF7iUle6rNugfURwNmCGG3Z/2+opxAMLs1nND4bhEG5PuxTIggDBoeYYSujAlLtsupzOHYJQgPS3pivwXIA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-syntax-class-properties@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.8.3.tgz#6cb933a8872c8d359bfde69bbeaae5162fd1e8f7" - integrity sha512-UcAyQWg2bAN647Q+O811tG9MrJ38Z10jjhQdKNAL8fsyPzE3cCN/uT+f55cFVY4aGO4jqJAvmqsuY3GQDwAoXg== +"@babel/plugin-syntax-decorators@^7.10.4": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.10.4.tgz#6853085b2c429f9d322d02f5a635018cdeb2360c" + integrity sha512-2NaoC6fAk2VMdhY1eerkfHV+lVYC1u8b+jmRJISqANCJlTxYy19HGdIkkQtix2UtkcPuPu+IlDgrVseZnU03bw== dependencies: - "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-syntax-dynamic-import@^7.2.0", "@babel/plugin-syntax-dynamic-import@^7.8.0": +"@babel/plugin-syntax-dynamic-import@^7.8.0", "@babel/plugin-syntax-dynamic-import@^7.8.3": version "7.8.3" resolved "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== dependencies: "@babel/helper-plugin-utils" "^7.8.0" +"@babel/plugin-syntax-export-default-from@^7.10.4": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.10.4.tgz#e5494f95006355c10292a0ff1ce42a5746002ec8" + integrity sha512-79V6r6Pgudz0RnuMGp5xidu6Z+bPFugh8/Q9eDHonmLp4wKFAZDwygJwYgCzuDu8lFA/sYyT+mc5y2wkd7bTXA== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-export-namespace-from@^7.8.3": version "7.8.3" resolved "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a" @@ -778,6 +586,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.3" +"@babel/plugin-syntax-import-meta@^7.8.3": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-json-strings@^7.8.0", "@babel/plugin-syntax-json-strings@^7.8.3": version "7.8.3" resolved "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" @@ -792,20 +607,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-syntax-logical-assignment-operators@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.8.3.tgz#3995d7d7ffff432f6ddc742b47e730c054599897" - integrity sha512-Zpg2Sgc++37kuFl6ppq2Q7Awc6E6AIW671x5PY8E/f7MCIyPPGK/EoeZXvvY3P42exZ3Q4/t3YOzP/HiN79jDg== - dependencies: - "@babel/helper-plugin-utils" "^7.8.3" - "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0", "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": version "7.8.3" resolved "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" @@ -813,20 +621,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-numeric-separator@^7.10.4": +"@babel/plugin-syntax-numeric-separator@^7.10.4", "@babel/plugin-syntax-numeric-separator@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-syntax-numeric-separator@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.8.3.tgz#0e3fb63e09bea1b11e96467271c8308007e7c41f" - integrity sha512-H7dCMAdN83PcCmqmkHB5dtp+Xa9a6LKSvA2hiFBC/5alSHxM5VgWZXFqDi0YFe8XNGT6iCa+z4V4zSt/PdZ7Dw== - dependencies: - "@babel/helper-plugin-utils" "^7.8.3" - "@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.0", "@babel/plugin-syntax-object-rest-spread@^7.8.3": version "7.8.3" resolved "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" @@ -855,7 +656,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-arrow-functions@^7.0.0", "@babel/plugin-transform-arrow-functions@^7.10.4": +"@babel/plugin-syntax-typescript@^7.10.4": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.10.4.tgz#2f55e770d3501e83af217d782cb7517d7bb34d25" + integrity sha512-oSAEz1YkBCAKr5Yiq8/BNtvSAPwkp/IyUnwZogd8p+F0RuYQQrLeRUzIQhueQTTBy/F+a40uS7OFKxnkRvmvFQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-transform-arrow-functions@^7.0.0", "@babel/plugin-transform-arrow-functions@^7.10.4", "@babel/plugin-transform-arrow-functions@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.10.4.tgz#e22960d77e697c74f41c501d44d73dbf8a6a64cd" integrity sha512-9J/oD1jV0ZCBcgnoFWFq1vJd4msoKb/TCpGNFyyLt0zABdcvgK3aYikZ8HjzB14c26bc7E3Q1yugpwGy2aTPNA== @@ -878,21 +686,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-block-scoping@^7.0.0": +"@babel/plugin-transform-block-scoping@^7.0.0", "@babel/plugin-transform-block-scoping@^7.10.4", "@babel/plugin-transform-block-scoping@^7.8.3": version "7.11.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.11.1.tgz#5b7efe98852bef8d652c0b28144cd93a9e4b5215" integrity sha512-00dYeDE0EVEHuuM+26+0w/SCL0BH2Qy7LwHuI4Hi4MH5gkC8/AqMN5uWFJIsoXZrAphiMm1iXzBw6L2T+eA0ew== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-block-scoping@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.10.5.tgz#b81b8aafefbfe68f0f65f7ef397b9ece68a6037d" - integrity sha512-6Ycw3hjpQti0qssQcA6AMSFDHeNJ++R6dIMnpRqUjFeBBTmTDPa8zgF90OVfTvAo11mXZTlVUViY1g8ffrURLg== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-classes@^7.0.0", "@babel/plugin-transform-classes@^7.10.4": +"@babel/plugin-transform-classes@^7.0.0", "@babel/plugin-transform-classes@^7.10.4", "@babel/plugin-transform-classes@^7.9.5": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.10.4.tgz#405136af2b3e218bc4a1926228bc917ab1a0adc7" integrity sha512-2oZ9qLjt161dn1ZE0Ms66xBncQH4In8Sqw1YWgBUZuGVJJS5c0OFZXL6dP2MRHrkU/eKhWg8CzFJhRQl50rQxA== @@ -913,14 +714,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-destructuring@^7.0.0", "@babel/plugin-transform-destructuring@^7.10.4": +"@babel/plugin-transform-destructuring@^7.0.0", "@babel/plugin-transform-destructuring@^7.10.4", "@babel/plugin-transform-destructuring@^7.9.5": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.10.4.tgz#70ddd2b3d1bea83d01509e9bb25ddb3a74fc85e5" integrity sha512-+WmfvyfsyF603iPa6825mq6Qrb7uLjTOsa3XOFzlYcYDHSS4QmpOWOL0NNBY5qMbvrcf3tq0Cw+v4lxswOBpgA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-dotall-regex@^7.10.4": +"@babel/plugin-transform-dotall-regex@^7.10.4", "@babel/plugin-transform-dotall-regex@^7.4.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.10.4.tgz#469c2062105c1eb6a040eaf4fac4b488078395ee" integrity sha512-ZEAVvUTCMlMFAbASYSVQoxIbHm2OkG2MseW6bV2JjIygOjdVv8tuxrCTzj1+Rynh7ODb8GivUy7dzEXzEhuPaA== @@ -928,14 +729,6 @@ "@babel/helper-create-regexp-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-dotall-regex@^7.4.4": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.8.3.tgz#c3c6ec5ee6125c6993c5cbca20dc8621a9ea7a6e" - integrity sha512-kLs1j9Nn4MQoBYdRXH6AeaXMbEJFaFu/v1nQkvib6QzTj8MZI5OQzqmD83/2jEM1z0DLilra5aWO5YpyC0ALIw== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.8.3" - "@babel/helper-plugin-utils" "^7.8.3" - "@babel/plugin-transform-duplicate-keys@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.10.4.tgz#697e50c9fee14380fe843d1f306b295617431e47" @@ -967,7 +760,7 @@ "@babel/helper-plugin-utils" "^7.8.3" "@babel/plugin-syntax-flow" "^7.8.3" -"@babel/plugin-transform-for-of@^7.0.0", "@babel/plugin-transform-for-of@^7.10.4": +"@babel/plugin-transform-for-of@^7.0.0", "@babel/plugin-transform-for-of@^7.10.4", "@babel/plugin-transform-for-of@^7.9.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.10.4.tgz#c08892e8819d3a5db29031b115af511dbbfebae9" integrity sha512-ItdQfAzu9AlEqmusA/65TqJ79eRcgGmpPPFvBnGILXZH975G0LNjP1yjHvGgfuCxqrPPueXOPe+FsvxmxKiHHQ== @@ -1005,7 +798,7 @@ "@babel/helper-plugin-utils" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-commonjs@^7.0.0", "@babel/plugin-transform-modules-commonjs@^7.10.4": +"@babel/plugin-transform-modules-commonjs@^7.0.0", "@babel/plugin-transform-modules-commonjs@^7.10.4", "@babel/plugin-transform-modules-commonjs@^7.4.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz#66667c3eeda1ebf7896d41f1f16b17105a2fbca0" integrity sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w== @@ -1015,16 +808,6 @@ "@babel/helper-simple-access" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-commonjs@^7.4.4": - version "7.9.6" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.9.6.tgz#64b7474a4279ee588cacd1906695ca721687c277" - integrity sha512-7H25fSlLcn+iYimmsNe3uK1at79IE6SKW9q0/QeEHTMC9MdOZ+4bA+T1VFB5fgOqBWoqlifXRzYD0JPdmIrgSQ== - dependencies: - "@babel/helper-module-transforms" "^7.9.0" - "@babel/helper-plugin-utils" "^7.8.3" - "@babel/helper-simple-access" "^7.8.3" - babel-plugin-dynamic-import-node "^2.3.3" - "@babel/plugin-transform-modules-systemjs@^7.10.4": version "7.10.5" resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.10.5.tgz#6270099c854066681bae9e05f87e1b9cadbe8c85" @@ -1065,7 +848,7 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-replace-supers" "^7.10.4" -"@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.10.4": +"@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.10.4", "@babel/plugin-transform-parameters@^7.9.5": version "7.10.5" resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.10.5.tgz#59d339d58d0b1950435f4043e74e2510005e2c4a" integrity sha512-xPHwUj5RdFV8l1wuYiu5S9fqWGM2DrYc24TMvUiRrPVm+SM3XeqU9BcokQX/kEUe+p2RBwy+yoiR1w/Blq6ubw== @@ -1080,7 +863,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-react-constant-elements@^7.0.0", "@babel/plugin-transform-react-constant-elements@^7.2.0", "@babel/plugin-transform-react-constant-elements@^7.6.3", "@babel/plugin-transform-react-constant-elements@^7.7.4": +"@babel/plugin-transform-react-constant-elements@^7.7.4", "@babel/plugin-transform-react-constant-elements@^7.9.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.10.4.tgz#0f485260bf1c29012bb973e7e404749eaac12c9e" integrity sha512-cYmQBW1pXrqBte1raMkAulXmi7rjg3VI6ZLg9QIic8Hq7BtYXaWuZSxsr2siOMI6SWwpxjWfnwhTUrd7JlAV7g== @@ -1151,14 +934,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-shorthand-properties@^7.0.0", "@babel/plugin-transform-shorthand-properties@^7.10.4": +"@babel/plugin-transform-shorthand-properties@^7.0.0", "@babel/plugin-transform-shorthand-properties@^7.10.4", "@babel/plugin-transform-shorthand-properties@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.10.4.tgz#9fd25ec5cdd555bb7f473e5e6ee1c971eede4dd6" integrity sha512-AC2K/t7o07KeTIxMoHneyX90v3zkm5cjHJEokrPEAGEy3UCp8sLKfnfOIGdZ194fyN4wfX/zZUWT9trJZ0qc+Q== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-spread@^7.0.0", "@babel/plugin-transform-spread@^7.11.0": +"@babel/plugin-transform-spread@^7.0.0", "@babel/plugin-transform-spread@^7.11.0", "@babel/plugin-transform-spread@^7.8.3": version "7.11.0" resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.11.0.tgz#fa84d300f5e4f57752fe41a6d1b3c554f13f17cc" integrity sha512-UwQYGOqIdQJe4aWNyS7noqAnN2VbaczPLiEtln+zPowRNlD+79w3oi2TWfYe0eZgd+gjZCbsydN7lzWysDt+gw== @@ -1166,13 +949,6 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0" -"@babel/plugin-transform-spread@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.10.4.tgz#4e2c85ea0d6abaee1b24dcfbbae426fe8d674cff" - integrity sha512-1e/51G/Ni+7uH5gktbWv+eCED9pP8ZpRhZB3jOaI3mmzfvJTWHkuyYTv0Z5PYtyM+Tr2Ccr9kUdQxn60fI5WuQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-transform-sticky-regex@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.10.4.tgz#8f3889ee8657581130a29d9cc91d7c73b7c4a28d" @@ -1181,7 +957,7 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-regex" "^7.10.4" -"@babel/plugin-transform-template-literals@^7.0.0", "@babel/plugin-transform-template-literals@^7.10.4": +"@babel/plugin-transform-template-literals@^7.0.0", "@babel/plugin-transform-template-literals@^7.10.4", "@babel/plugin-transform-template-literals@^7.8.3": version "7.10.5" resolved "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.10.5.tgz#78bc5d626a6642db3312d9d0f001f5e7639fde8c" integrity sha512-V/lnPGIb+KT12OQikDvgSuesRX14ck5FfJXt6+tXhdkJ+Vsd0lDCVtF6jcB4rNClYFzaB2jusZ+lNISDk2mMMw== @@ -1196,6 +972,15 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" +"@babel/plugin-transform-typescript@^7.10.4": + version "7.11.0" + resolved "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.11.0.tgz#2b4879676af37342ebb278216dd090ac67f13abb" + integrity sha512-edJsNzTtvb3MaXQwj8403B7mZoGu9ElDJQZOKjGUnvilquxBA3IQoEIOvkX/1O8xfAsnHS/oQhe2w/IXrr+w0w== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.10.5" + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-syntax-typescript" "^7.10.4" + "@babel/plugin-transform-unicode-escapes@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.10.4.tgz#feae523391c7651ddac115dae0a9d06857892007" @@ -1211,88 +996,10 @@ "@babel/helper-create-regexp-features-plugin" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/polyfill@^7.8.7": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.10.4.tgz#915e5bfe61490ac0199008e35ca9d7d151a8e45a" - integrity sha512-8BYcnVqQ5kMD2HXoHInBH7H1b/uP3KdnwCYXOqFnXqguOyuu443WXusbIUbWEfY3Z0Txk0M1uG/8YuAMhNl6zg== - dependencies: - core-js "^2.6.5" - regenerator-runtime "^0.13.4" - -"@babel/preset-env@^7.4.5", "@babel/preset-env@^7.9.5": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.10.4.tgz#fbf57f9a803afd97f4f32e4f798bb62e4b2bef5f" - integrity sha512-tcmuQ6vupfMZPrLrc38d0sF2OjLT3/bZ0dry5HchNCQbrokoQi4reXqclvkkAT5b+gWc23meVWpve5P/7+w/zw== - dependencies: - "@babel/compat-data" "^7.10.4" - "@babel/helper-compilation-targets" "^7.10.4" - "@babel/helper-module-imports" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-proposal-async-generator-functions" "^7.10.4" - "@babel/plugin-proposal-class-properties" "^7.10.4" - "@babel/plugin-proposal-dynamic-import" "^7.10.4" - "@babel/plugin-proposal-json-strings" "^7.10.4" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.4" - "@babel/plugin-proposal-numeric-separator" "^7.10.4" - "@babel/plugin-proposal-object-rest-spread" "^7.10.4" - "@babel/plugin-proposal-optional-catch-binding" "^7.10.4" - "@babel/plugin-proposal-optional-chaining" "^7.10.4" - "@babel/plugin-proposal-private-methods" "^7.10.4" - "@babel/plugin-proposal-unicode-property-regex" "^7.10.4" - "@babel/plugin-syntax-async-generators" "^7.8.0" - "@babel/plugin-syntax-class-properties" "^7.10.4" - "@babel/plugin-syntax-dynamic-import" "^7.8.0" - "@babel/plugin-syntax-json-strings" "^7.8.0" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" - "@babel/plugin-syntax-numeric-separator" "^7.10.4" - "@babel/plugin-syntax-object-rest-spread" "^7.8.0" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" - "@babel/plugin-syntax-optional-chaining" "^7.8.0" - "@babel/plugin-syntax-top-level-await" "^7.10.4" - "@babel/plugin-transform-arrow-functions" "^7.10.4" - "@babel/plugin-transform-async-to-generator" "^7.10.4" - "@babel/plugin-transform-block-scoped-functions" "^7.10.4" - "@babel/plugin-transform-block-scoping" "^7.10.4" - "@babel/plugin-transform-classes" "^7.10.4" - "@babel/plugin-transform-computed-properties" "^7.10.4" - "@babel/plugin-transform-destructuring" "^7.10.4" - "@babel/plugin-transform-dotall-regex" "^7.10.4" - "@babel/plugin-transform-duplicate-keys" "^7.10.4" - "@babel/plugin-transform-exponentiation-operator" "^7.10.4" - "@babel/plugin-transform-for-of" "^7.10.4" - "@babel/plugin-transform-function-name" "^7.10.4" - "@babel/plugin-transform-literals" "^7.10.4" - "@babel/plugin-transform-member-expression-literals" "^7.10.4" - "@babel/plugin-transform-modules-amd" "^7.10.4" - "@babel/plugin-transform-modules-commonjs" "^7.10.4" - "@babel/plugin-transform-modules-systemjs" "^7.10.4" - "@babel/plugin-transform-modules-umd" "^7.10.4" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.10.4" - "@babel/plugin-transform-new-target" "^7.10.4" - "@babel/plugin-transform-object-super" "^7.10.4" - "@babel/plugin-transform-parameters" "^7.10.4" - "@babel/plugin-transform-property-literals" "^7.10.4" - "@babel/plugin-transform-regenerator" "^7.10.4" - "@babel/plugin-transform-reserved-words" "^7.10.4" - "@babel/plugin-transform-shorthand-properties" "^7.10.4" - "@babel/plugin-transform-spread" "^7.10.4" - "@babel/plugin-transform-sticky-regex" "^7.10.4" - "@babel/plugin-transform-template-literals" "^7.10.4" - "@babel/plugin-transform-typeof-symbol" "^7.10.4" - "@babel/plugin-transform-unicode-escapes" "^7.10.4" - "@babel/plugin-transform-unicode-regex" "^7.10.4" - "@babel/preset-modules" "^0.1.3" - "@babel/types" "^7.10.4" - browserslist "^4.12.0" - core-js-compat "^3.6.2" - invariant "^2.2.2" - levenary "^1.1.1" - semver "^5.5.0" - -"@babel/preset-env@^7.9.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.11.0.tgz#860ee38f2ce17ad60480c2021ba9689393efb796" - integrity sha512-2u1/k7rG/gTh02dylX2kL3S0IJNF+J6bfDSp4DI2Ma8QN6Y9x9pmAax59fsCk6QUQG0yqH47yJWA+u1I1LccAg== +"@babel/preset-env@^7.9.5", "@babel/preset-env@^7.9.6": + version "7.11.5" + resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.11.5.tgz#18cb4b9379e3e92ffea92c07471a99a2914e4272" + integrity sha512-kXqmW1jVcnB2cdueV+fyBM8estd5mlNfaQi6lwLgRwCby4edpavgbFhiBNjmWA3JpB/yZGSISa7Srf+TwxDQoA== dependencies: "@babel/compat-data" "^7.11.0" "@babel/helper-compilation-targets" "^7.10.4" @@ -1356,7 +1063,7 @@ "@babel/plugin-transform-unicode-escapes" "^7.10.4" "@babel/plugin-transform-unicode-regex" "^7.10.4" "@babel/preset-modules" "^0.1.3" - "@babel/types" "^7.11.0" + "@babel/types" "^7.11.5" browserslist "^4.12.0" core-js-compat "^3.6.2" invariant "^2.2.2" @@ -1382,7 +1089,7 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" -"@babel/preset-react@^7.0.0", "@babel/preset-react@^7.9.4": +"@babel/preset-react@^7.0.0", "@babel/preset-react@^7.8.3", "@babel/preset-react@^7.9.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.10.4.tgz#92e8a66d816f9911d11d4cc935be67adfc82dbcf" integrity sha512-BrHp4TgOIy4M19JAfO1LhycVXOPWdDbTRep7eVyatf174Hff+6Uk53sDyajqZPu8W1qXRBiYOfIamek6jA7YVw== @@ -1395,10 +1102,18 @@ "@babel/plugin-transform-react-jsx-source" "^7.10.4" "@babel/plugin-transform-react-pure-annotations" "^7.10.4" -"@babel/register@^7.9.0": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/register/-/register-7.10.5.tgz#354f3574895f1307f79efe37a51525e52fd38d89" - integrity sha512-eYHdLv43nyvmPn9bfNfrcC4+iYNwdQ8Pxk1MFJuU/U5LpSYl/PH4dFMazCYZDFVi8ueG3shvO+AQfLrxpYulQw== +"@babel/preset-typescript@^7.9.0": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.10.4.tgz#7d5d052e52a682480d6e2cc5aa31be61c8c25e36" + integrity sha512-SdYnvGPv+bLlwkF2VkJnaX/ni1sMNetcGI1+nThF1gyv6Ph8Qucc4ZZAjM5yZcE/AKRXIOTZz7eSRDWOEjPyRQ== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + "@babel/plugin-transform-typescript" "^7.10.4" + +"@babel/register@^7.10.5": + version "7.11.5" + resolved "https://registry.npmjs.org/@babel/register/-/register-7.11.5.tgz#79becf89e0ddd0fba8b92bc279bc0f5d2d7ce2ea" + integrity sha512-CAml0ioKX+kOAvBQDHa/+t1fgOt3qkTIz0TrRtRAT6XY0m5qYZXR85k6/sLCNPMGhYDlCFHCYuU0ybTJbvlC6w== dependencies: find-cache-dir "^2.0.0" lodash "^4.17.19" @@ -1406,7 +1121,7 @@ pirates "^4.0.0" source-map-support "^0.5.16" -"@babel/runtime-corejs2@^7.10.4": +"@babel/runtime-corejs2@^7.10.4", "@babel/runtime-corejs2@^7.8.7": version "7.11.2" resolved "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.11.2.tgz#700a03945ebad0d31ba6690fc8a6bcc9040faa47" integrity sha512-AC/ciV28adSSpEkBglONBWq4/Lvm6GAZuxIoyVtsnUpZMl0bxLtoChEnYAkP+47KyOCayZanojtflUEUJtR/6Q== @@ -1414,15 +1129,7 @@ core-js "^2.6.5" regenerator-runtime "^0.13.4" -"@babel/runtime-corejs2@^7.8.7": - version "7.10.3" - resolved "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.10.3.tgz#81bc99a96bfcb6db3f0dcf73fdc577cc554d341b" - integrity sha512-enKvnR/kKFbZFgXYo165wtSA5OeiTlgsnU4jV3vpKRhfWUJjLS6dfVcjIPeRcgJbgEgdgu0I+UyBWqu6c0GumQ== - dependencies: - core-js "^2.6.5" - regenerator-runtime "^0.13.4" - -"@babel/runtime-corejs3@^7.10.2": +"@babel/runtime-corejs3@^7.10.2", "@babel/runtime-corejs3@^7.8.3": version "7.10.3" resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.3.tgz#931ed6941d3954924a7aa967ee440e60c507b91a" integrity sha512-HA7RPj5xvJxQl429r5Cxr2trJwOfPjKiqhCXcdQPSqO2G0RHPZpXu4fkYmBaTKCp2c/jRaMK9GB/lN+7zvvFPw== @@ -1430,22 +1137,14 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime-corejs3@^7.8.3": - version "7.9.2" - resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.9.2.tgz#26fe4aa77e9f1ecef9b776559bbb8e84d34284b7" - integrity sha512-HHxmgxbIzOfFlZ+tdeRKtaxWOMUoCG5Mu3wKeUmOxjYrwb3AAHgnmtCUbPPK11/raIWLIBK250t8E2BPO0p7jA== - dependencies: - core-js-pure "^3.0.0" - regenerator-runtime "^0.13.4" - -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2", "@babel/runtime@^7.9.6": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz#303d8bd440ecd5a491eae6117fd3367698674c5c" - integrity sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg== +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": + version "7.11.2" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" + integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== dependencies: regenerator-runtime "^0.13.4" -"@babel/template@^7.10.4": +"@babel/template@^7.10.4", "@babel/template@^7.3.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA== @@ -1454,16 +1153,7 @@ "@babel/parser" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/template@^7.3.3", "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6": - version "7.8.6" - resolved "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" - integrity sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg== - dependencies: - "@babel/code-frame" "^7.8.3" - "@babel/parser" "^7.8.6" - "@babel/types" "^7.8.6" - -"@babel/traverse@7.10.5", "@babel/traverse@^7.10.4": +"@babel/traverse@7.10.5": version "7.10.5" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.5.tgz#77ce464f5b258be265af618d8fddf0536f20b564" integrity sha512-yc/fyv2gUjPqzTz0WHeRJH2pv7jA9kA7mBX2tXl/x5iOE81uaVPuGPtaYk7wmkx4b67mQ7NqI8rmT2pF47KYKQ== @@ -1478,7 +1168,7 @@ globals "^11.1.0" lodash "^4.17.19" -"@babel/traverse@^7.0.0", "@babel/traverse@^7.11.0", "@babel/traverse@^7.9.0": +"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.10.4", "@babel/traverse@^7.11.0": version "7.11.0" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.0.tgz#9b996ce1b98f53f7c3e4175115605d56ed07dd24" integrity sha512-ZB2V+LskoWKNpMq6E5UUCrjtDUh5IOTAyIl0dTjIEoXum/iKWkoIEKIRDnUucO6f+2FzNkE0oD4RLKoPIufDtg== @@ -1493,22 +1183,7 @@ globals "^11.1.0" lodash "^4.17.19" -"@babel/traverse@^7.1.0", "@babel/traverse@^7.7.4", "@babel/traverse@^7.8.6", "@babel/traverse@^7.9.6": - version "7.9.6" - resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.6.tgz#5540d7577697bf619cc57b92aa0f1c231a94f442" - integrity sha512-b3rAHSjbxy6VEAvlxM8OV/0X4XrG72zoxme6q1MOoe2vd0bEc+TwayhuC1+Dfgqh1QEG+pj7atQqvUprHIccsg== - dependencies: - "@babel/code-frame" "^7.8.3" - "@babel/generator" "^7.9.6" - "@babel/helper-function-name" "^7.9.5" - "@babel/helper-split-export-declaration" "^7.8.3" - "@babel/parser" "^7.9.6" - "@babel/types" "^7.9.6" - debug "^4.1.0" - globals "^11.1.0" - lodash "^4.17.13" - -"@babel/types@7.10.5", "@babel/types@^7.10.4", "@babel/types@^7.10.5": +"@babel/types@7.10.5": version "7.10.5" resolved "https://registry.npmjs.org/@babel/types/-/types-7.10.5.tgz#d88ae7e2fde86bfbfe851d4d81afa70a997b5d15" integrity sha512-ixV66KWfCI6GKoA/2H9v6bQdbfXEwwpOdQ8cRvb4F+eyvhlaHxWFMQB4+3d9QFJXZsiiiqVrewNV0DFEQpyT4Q== @@ -1517,19 +1192,10 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" -"@babel/types@^7.0.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.8.3", "@babel/types@^7.8.6", "@babel/types@^7.9.0", "@babel/types@^7.9.5", "@babel/types@^7.9.6": - version "7.9.6" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.9.6.tgz#2c5502b427251e9de1bd2dff95add646d95cc9f7" - integrity sha512-qxXzvBO//jO9ZnoasKF1uJzHd2+M6Q2ZPIVfnFps8JJvXy0ZBbwbNOmE6SGIY5XOY6d1Bo5lb9d9RJ8nv3WSeA== - dependencies: - "@babel/helper-validator-identifier" "^7.9.5" - lodash "^4.17.13" - to-fast-properties "^2.0.0" - -"@babel/types@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.0.tgz#2ae6bf1ba9ae8c3c43824e5861269871b206e90d" - integrity sha512-O53yME4ZZI0jO1EVGtF1ePGl0LHirG4P1ibcD80XyzZcKhcMFeCXmh4Xb1ifGBIV233Qg12x4rBfQgA+tmOukA== +"@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.9.5": + version "7.11.5" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" + integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q== dependencies: "@babel/helper-validator-identifier" "^7.10.4" lodash "^4.17.19" @@ -1553,7 +1219,7 @@ exec-sh "^0.3.2" minimist "^1.2.0" -"@cypress/listr-verbose-renderer@0.4.1": +"@cypress/listr-verbose-renderer@^0.4.1": version "0.4.1" resolved "https://registry.npmjs.org/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#a77492f4b11dcc7c446a34b3e28721afd33c642a" integrity sha1-p3SS9LEdzHxEajSz4ochr9M8ZCo= @@ -1563,7 +1229,7 @@ date-fns "^1.27.2" figures "^1.7.0" -"@cypress/request@2.88.5": +"@cypress/request@^2.88.5": version "2.88.5" resolved "https://registry.npmjs.org/@cypress/request/-/request-2.88.5.tgz#8d7ecd17b53a849cfd5ab06d5abe7d84976375d7" integrity sha512-TzEC1XMi1hJkywWpRfD2clreTa/Z+lOrXDCxxBTBPEcY5azdPi56A6Xw+O4tWJnaJH3iIE7G5aDXZC6JgRZLcA== @@ -1589,7 +1255,7 @@ tunnel-agent "^0.6.0" uuid "^3.3.2" -"@cypress/xvfb@1.2.4": +"@cypress/xvfb@^1.2.4": version "1.2.4" resolved "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz#2daf42e8275b39f4aa53c14214e557bd14e7748a" integrity sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q== @@ -1645,7 +1311,7 @@ resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== -"@emotion/is-prop-valid@0.8.8", "@emotion/is-prop-valid@^0.8.1": +"@emotion/is-prop-valid@0.8.8", "@emotion/is-prop-valid@^0.8.1", "@emotion/is-prop-valid@^0.8.6": version "0.8.8" resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== @@ -1785,6 +1451,41 @@ unique-filename "^1.1.1" which "^1.3.1" +"@fmvilas/pseudo-yaml-ast@^0.3.1": + version "0.3.1" + resolved "https://registry.npmjs.org/@fmvilas/pseudo-yaml-ast/-/pseudo-yaml-ast-0.3.1.tgz#66c5df2c2d76ba8571dc5bd98fda4d7dce6121de" + integrity sha512-8OAB74W2a9M3k9bjYD8AjVXkX+qO8c0SqNT5HlgOqx7AxSw8xdksEcZp7gFtfi+4njSxT6+76ZR+1ubjAwQHOg== + dependencies: + yaml-ast-parser "0.0.43" + +"@gitbeaker/core@^23.5.0": + version "23.5.0" + resolved "https://registry.npmjs.org/@gitbeaker/core/-/core-23.5.0.tgz#e0be44eecd0d7bf5418c161997c36d45b0c1ba81" + integrity sha512-5geLk7SxAttABBJZxfuSp72lSYWRyRId583vGCpKB6ISkoDhP+vJxdE6ypmtOJPV4CaSRZhluAGAZd968pi9ng== + dependencies: + "@gitbeaker/requester-utils" "^23.5.0" + form-data "^3.0.0" + li "^1.3.0" + xcase "^2.0.1" + +"@gitbeaker/node@^23.5.0": + version "23.5.0" + resolved "https://registry.npmjs.org/@gitbeaker/node/-/node-23.5.0.tgz#0243be78aad148e7d6b5d79b7acc340748b5fa94" + integrity sha512-GEyhcrF1Lm8YmsmwntfzuhXnq00TrG14wNZP2Hg+DGgexG25eBbbcyFXuFZlBFSaGMQlsc8aPeuEyfyGmgpwnw== + dependencies: + "@gitbeaker/core" "^23.5.0" + "@gitbeaker/requester-utils" "^23.5.0" + got "^11.1.4" + xcase "^2.0.1" + +"@gitbeaker/requester-utils@^23.5.0": + version "23.5.0" + resolved "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-23.5.0.tgz#e6f5d0216cb978be95e6bf40adf606f23d426f14" + integrity sha512-MdmInOO4unkApvtbv6PnIpDYXosgZgClSOqbxF5S4aJRCZVTJ6oPjMoNP8luhyT9xQeknpKxn9Iv8psEh7IC1Q== + dependencies: + query-string "^6.12.1" + xcase "^2.0.1" + "@graphql-codegen/cli@^1.17.7": version "1.17.7" resolved "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-1.17.7.tgz#4ba45106403a3aca36585bf04f0169fda38ac9df" @@ -2159,6 +1860,11 @@ prop-types "^15.6.2" scheduler "^0.19.0" +"@icons/material@^0.2.4": + version "0.2.4" + resolved "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8" + integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b" @@ -2174,171 +1880,166 @@ resolved "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== -"@jest/console@^26.0.1": - version "26.0.1" - resolved "https://registry.npmjs.org/@jest/console/-/console-26.0.1.tgz#62b3b2fa8990f3cbffbef695c42ae9ddbc8f4b39" - integrity sha512-9t1KUe/93coV1rBSxMmBAOIK3/HVpwxArCA1CxskKyRiv6o8J70V8C/V3OJminVCTa2M0hQI9AWRd5wxu2dAHw== +"@jest/console@^26.3.0": + version "26.3.0" + resolved "https://registry.npmjs.org/@jest/console/-/console-26.3.0.tgz#ed04063efb280c88ba87388b6f16427c0a85c856" + integrity sha512-/5Pn6sJev0nPUcAdpJHMVIsA8sKizL2ZkcKPE5+dJrCccks7tcM7c9wbgHudBJbxXLoTbqsHkG1Dofoem4F09w== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" + "@types/node" "*" chalk "^4.0.0" - jest-message-util "^26.0.1" - jest-util "^26.0.1" + jest-message-util "^26.3.0" + jest-util "^26.3.0" slash "^3.0.0" -"@jest/core@^26.0.1": - version "26.0.1" - resolved "https://registry.npmjs.org/@jest/core/-/core-26.0.1.tgz#aa538d52497dfab56735efb00e506be83d841fae" - integrity sha512-Xq3eqYnxsG9SjDC+WLeIgf7/8KU6rddBxH+SCt18gEpOhAGYC/Mq+YbtlNcIdwjnnT+wDseXSbU0e5X84Y4jTQ== +"@jest/core@^26.4.2": + version "26.4.2" + resolved "https://registry.npmjs.org/@jest/core/-/core-26.4.2.tgz#85d0894f31ac29b5bab07aa86806d03dd3d33edc" + integrity sha512-sDva7YkeNprxJfepOctzS8cAk9TOekldh+5FhVuXS40+94SHbiicRO1VV2tSoRtgIo+POs/Cdyf8p76vPTd6dg== dependencies: - "@jest/console" "^26.0.1" - "@jest/reporters" "^26.0.1" - "@jest/test-result" "^26.0.1" - "@jest/transform" "^26.0.1" - "@jest/types" "^26.0.1" + "@jest/console" "^26.3.0" + "@jest/reporters" "^26.4.1" + "@jest/test-result" "^26.3.0" + "@jest/transform" "^26.3.0" + "@jest/types" "^26.3.0" + "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" - jest-changed-files "^26.0.1" - jest-config "^26.0.1" - jest-haste-map "^26.0.1" - jest-message-util "^26.0.1" + jest-changed-files "^26.3.0" + jest-config "^26.4.2" + jest-haste-map "^26.3.0" + jest-message-util "^26.3.0" jest-regex-util "^26.0.0" - jest-resolve "^26.0.1" - jest-resolve-dependencies "^26.0.1" - jest-runner "^26.0.1" - jest-runtime "^26.0.1" - jest-snapshot "^26.0.1" - jest-util "^26.0.1" - jest-validate "^26.0.1" - jest-watcher "^26.0.1" + jest-resolve "^26.4.0" + jest-resolve-dependencies "^26.4.2" + jest-runner "^26.4.2" + jest-runtime "^26.4.2" + jest-snapshot "^26.4.2" + jest-util "^26.3.0" + jest-validate "^26.4.2" + jest-watcher "^26.3.0" micromatch "^4.0.2" p-each-series "^2.1.0" rimraf "^3.0.0" slash "^3.0.0" strip-ansi "^6.0.0" -"@jest/environment@^26.0.1": - version "26.0.1" - resolved "https://registry.npmjs.org/@jest/environment/-/environment-26.0.1.tgz#82f519bba71959be9b483675ee89de8c8f72a5c8" - integrity sha512-xBDxPe8/nx251u0VJ2dFAFz2H23Y98qdIaNwnMK6dFQr05jc+Ne/2np73lOAx+5mSBO/yuQldRrQOf6hP1h92g== +"@jest/environment@^26.3.0": + version "26.3.0" + resolved "https://registry.npmjs.org/@jest/environment/-/environment-26.3.0.tgz#e6953ab711ae3e44754a025f838bde1a7fd236a0" + integrity sha512-EW+MFEo0DGHahf83RAaiqQx688qpXgl99wdb8Fy67ybyzHwR1a58LHcO376xQJHfmoXTu89M09dH3J509cx2AA== dependencies: - "@jest/fake-timers" "^26.0.1" - "@jest/types" "^26.0.1" - jest-mock "^26.0.1" + "@jest/fake-timers" "^26.3.0" + "@jest/types" "^26.3.0" + "@types/node" "*" + jest-mock "^26.3.0" -"@jest/fake-timers@^26.0.1": - version "26.0.1" - resolved "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.0.1.tgz#f7aeff13b9f387e9d0cac9a8de3bba538d19d796" - integrity sha512-Oj/kCBnTKhm7CR+OJSjZty6N1bRDr9pgiYQr4wY221azLz5PHi08x/U+9+QpceAYOWheauLP8MhtSVFrqXQfhg== +"@jest/fake-timers@^26.3.0": + version "26.3.0" + resolved "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.3.0.tgz#f515d4667a6770f60ae06ae050f4e001126c666a" + integrity sha512-ZL9ytUiRwVP8ujfRepffokBvD2KbxbqMhrXSBhSdAhISCw3gOkuntisiSFv+A6HN0n0fF4cxzICEKZENLmW+1A== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" "@sinonjs/fake-timers" "^6.0.1" - jest-message-util "^26.0.1" - jest-mock "^26.0.1" - jest-util "^26.0.1" + "@types/node" "*" + jest-message-util "^26.3.0" + jest-mock "^26.3.0" + jest-util "^26.3.0" -"@jest/globals@^26.0.1": - version "26.0.1" - resolved "https://registry.npmjs.org/@jest/globals/-/globals-26.0.1.tgz#3f67b508a7ce62b6e6efc536f3d18ec9deb19a9c" - integrity sha512-iuucxOYB7BRCvT+TYBzUqUNuxFX1hqaR6G6IcGgEqkJ5x4htNKo1r7jk1ji9Zj8ZMiMw0oB5NaA7k5Tx6MVssA== +"@jest/globals@^26.4.2": + version "26.4.2" + resolved "https://registry.npmjs.org/@jest/globals/-/globals-26.4.2.tgz#73c2a862ac691d998889a241beb3dc9cada40d4a" + integrity sha512-Ot5ouAlehhHLRhc+sDz2/9bmNv9p5ZWZ9LE1pXGGTCXBasmi5jnYjlgYcYt03FBwLmZXCZ7GrL29c33/XRQiow== dependencies: - "@jest/environment" "^26.0.1" - "@jest/types" "^26.0.1" - expect "^26.0.1" + "@jest/environment" "^26.3.0" + "@jest/types" "^26.3.0" + expect "^26.4.2" -"@jest/reporters@^26.0.1": - version "26.0.1" - resolved "https://registry.npmjs.org/@jest/reporters/-/reporters-26.0.1.tgz#14ae00e7a93e498cec35b0c00ab21c375d9b078f" - integrity sha512-NWWy9KwRtE1iyG/m7huiFVF9YsYv/e+mbflKRV84WDoJfBqUrNRyDbL/vFxQcYLl8IRqI4P3MgPn386x76Gf2g== +"@jest/reporters@^26.4.1": + version "26.4.1" + resolved "https://registry.npmjs.org/@jest/reporters/-/reporters-26.4.1.tgz#3b4d6faf28650f3965f8b97bc3d114077fb71795" + integrity sha512-aROTkCLU8++yiRGVxLsuDmZsQEKO6LprlrxtAuzvtpbIFl3eIjgIf3EUxDKgomkS25R9ZzwGEdB5weCcBZlrpQ== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^26.0.1" - "@jest/test-result" "^26.0.1" - "@jest/transform" "^26.0.1" - "@jest/types" "^26.0.1" + "@jest/console" "^26.3.0" + "@jest/test-result" "^26.3.0" + "@jest/transform" "^26.3.0" + "@jest/types" "^26.3.0" chalk "^4.0.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" glob "^7.1.2" graceful-fs "^4.2.4" istanbul-lib-coverage "^3.0.0" - istanbul-lib-instrument "^4.0.0" + istanbul-lib-instrument "^4.0.3" istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^4.0.0" istanbul-reports "^3.0.2" - jest-haste-map "^26.0.1" - jest-resolve "^26.0.1" - jest-util "^26.0.1" - jest-worker "^26.0.0" + jest-haste-map "^26.3.0" + jest-resolve "^26.4.0" + jest-util "^26.3.0" + jest-worker "^26.3.0" slash "^3.0.0" source-map "^0.6.0" string-length "^4.0.1" terminal-link "^2.0.0" - v8-to-istanbul "^4.1.3" + v8-to-istanbul "^5.0.1" optionalDependencies: - node-notifier "^7.0.0" + node-notifier "^8.0.0" -"@jest/source-map@^26.0.0": - version "26.0.0" - resolved "https://registry.npmjs.org/@jest/source-map/-/source-map-26.0.0.tgz#fd7706484a7d3faf7792ae29783933bbf48a4749" - integrity sha512-S2Z+Aj/7KOSU2TfW0dyzBze7xr95bkm5YXNUqqCek+HE0VbNNSNzrRwfIi5lf7wvzDTSS0/ib8XQ1krFNyYgbQ== +"@jest/source-map@^26.3.0": + version "26.3.0" + resolved "https://registry.npmjs.org/@jest/source-map/-/source-map-26.3.0.tgz#0e646e519883c14c551f7b5ae4ff5f1bfe4fc3d9" + integrity sha512-hWX5IHmMDWe1kyrKl7IhFwqOuAreIwHhbe44+XH2ZRHjrKIh0LO5eLQ/vxHFeAfRwJapmxuqlGAEYLadDq6ZGQ== dependencies: callsites "^3.0.0" graceful-fs "^4.2.4" source-map "^0.6.0" -"@jest/test-result@^26.0.1": - version "26.0.1" - resolved "https://registry.npmjs.org/@jest/test-result/-/test-result-26.0.1.tgz#1ffdc1ba4bc289919e54b9414b74c9c2f7b2b718" - integrity sha512-oKwHvOI73ICSYRPe8WwyYPTtiuOAkLSbY8/MfWF3qDEd/sa8EDyZzin3BaXTqufir/O/Gzea4E8Zl14XU4Mlyg== +"@jest/test-result@^26.3.0": + version "26.3.0" + resolved "https://registry.npmjs.org/@jest/test-result/-/test-result-26.3.0.tgz#46cde01fa10c0aaeb7431bf71e4a20d885bc7fdb" + integrity sha512-a8rbLqzW/q7HWheFVMtghXV79Xk+GWwOK1FrtimpI5n1la2SY0qHri3/b0/1F0Ve0/yJmV8pEhxDfVwiUBGtgg== dependencies: - "@jest/console" "^26.0.1" - "@jest/types" "^26.0.1" + "@jest/console" "^26.3.0" + "@jest/types" "^26.3.0" "@types/istanbul-lib-coverage" "^2.0.0" collect-v8-coverage "^1.0.0" -"@jest/test-sequencer@^26.0.1": - version "26.0.1" - resolved "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.0.1.tgz#b0563424728f3fe9e75d1442b9ae4c11da73f090" - integrity sha512-ssga8XlwfP8YjbDcmVhwNlrmblddMfgUeAkWIXts1V22equp2GMIHxm7cyeD5Q/B0ZgKPK/tngt45sH99yLLGg== +"@jest/test-sequencer@^26.4.2": + version "26.4.2" + resolved "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.4.2.tgz#58a3760a61eec758a2ce6080201424580d97cbba" + integrity sha512-83DRD8N3M0tOhz9h0bn6Kl6dSp+US6DazuVF8J9m21WAp5x7CqSMaNycMP0aemC/SH/pDQQddbsfHRTBXVUgog== dependencies: - "@jest/test-result" "^26.0.1" + "@jest/test-result" "^26.3.0" graceful-fs "^4.2.4" - jest-haste-map "^26.0.1" - jest-runner "^26.0.1" - jest-runtime "^26.0.1" + jest-haste-map "^26.3.0" + jest-runner "^26.4.2" + jest-runtime "^26.4.2" -"@jest/transform@^26.0.1": - version "26.0.1" - resolved "https://registry.npmjs.org/@jest/transform/-/transform-26.0.1.tgz#0e3ecbb34a11cd4b2080ed0a9c4856cf0ceb0639" - integrity sha512-pPRkVkAQ91drKGbzCfDOoHN838+FSbYaEAvBXvKuWeeRRUD8FjwXkqfUNUZL6Ke48aA/1cqq/Ni7kVMCoqagWA== +"@jest/transform@^26.3.0": + version "26.3.0" + resolved "https://registry.npmjs.org/@jest/transform/-/transform-26.3.0.tgz#c393e0e01459da8a8bfc6d2a7c2ece1a13e8ba55" + integrity sha512-Isj6NB68QorGoFWvcOjlUhpkT56PqNIsXKR7XfvoDlCANn/IANlh8DrKAA2l2JKC3yWSMH5wS0GwuQM20w3b2A== dependencies: "@babel/core" "^7.1.0" - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" babel-plugin-istanbul "^6.0.0" chalk "^4.0.0" convert-source-map "^1.4.0" fast-json-stable-stringify "^2.0.0" graceful-fs "^4.2.4" - jest-haste-map "^26.0.1" + jest-haste-map "^26.3.0" jest-regex-util "^26.0.0" - jest-util "^26.0.1" + jest-util "^26.3.0" micromatch "^4.0.2" pirates "^4.0.1" slash "^3.0.0" source-map "^0.6.1" write-file-atomic "^3.0.0" -"@jest/types@^24.9.0": - version "24.9.0" - resolved "https://registry.npmjs.org/@jest/types/-/types-24.9.0.tgz#63cb26cb7500d069e5a389441a7c6ab5e909fc59" - integrity sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw== - dependencies: - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^1.1.1" - "@types/yargs" "^13.0.0" - "@jest/types@^25.5.0": version "25.5.0" resolved "https://registry.npmjs.org/@jest/types/-/types-25.5.0.tgz#4d6a4793f7b9599fc3680877b856a97dbccf2a9d" @@ -2349,16 +2050,22 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" -"@jest/types@^26.0.1": - version "26.0.1" - resolved "https://registry.npmjs.org/@jest/types/-/types-26.0.1.tgz#b78333fbd113fa7aec8d39de24f88de8686dac67" - integrity sha512-IbtjvqI9+eS1qFnOIEL7ggWmT+iK/U+Vde9cGWtYb/b6XgKb3X44ZAe/z9YZzoAAZ/E92m0DqrilF934IGNnQA== +"@jest/types@^26.3.0": + version "26.3.0" + resolved "https://registry.npmjs.org/@jest/types/-/types-26.3.0.tgz#97627bf4bdb72c55346eef98e3b3f7ddc4941f71" + integrity sha512-BDPG23U0qDeAvU4f99haztXwdAg3hz4El95LkAM+tHAqqhiVzRpEGHHU8EDxT/AnxOrA65YjLBwDahdJ9pTLJQ== dependencies: "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^1.1.1" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@jsdevtools/ono@^7.1.3": + version "7.1.3" + resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" + integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== + "@kyleshockey/object-assign-deep@^0.4.2": version "0.4.2" resolved "https://registry.npmjs.org/@kyleshockey/object-assign-deep/-/object-assign-deep-0.4.2.tgz#84900f0eefc372798f4751b5262830b8208922ec" @@ -2371,14 +2078,18 @@ dependencies: stream "^0.0.2" -"@kyma-project/asyncapi-react@^0.6.1": - version "0.6.2" - resolved "https://registry.npmjs.org/@kyma-project/asyncapi-react/-/asyncapi-react-0.6.2.tgz#99539f630b511d4f7298805dfaf545956192141c" - integrity sha512-yGgYv0XyxI5PxNv0GzTqABsXKwFETlZwBWDqwfUtwirTOTIFyLU69lHbMtM9gn0hPFciP09uHmKt8+8gOpMKvA== +"@kyma-project/asyncapi-react@^0.11.0": + version "0.11.2" + resolved "https://registry.npmjs.org/@kyma-project/asyncapi-react/-/asyncapi-react-0.11.2.tgz#b523b0843da7c29a0d7569b3f31d7d8cbdf84b47" + integrity sha512-Wo6CgM3pzZaRMCVwXaEIrlXMuFtRNGli1GtFgMHQUB686daSCqSvRZUyitDHTZLofL3OI9JeYh5m6SjPji6bxg== dependencies: - "@asyncapi/parser" "0.16.2" + "@asyncapi/avro-schema-parser" "^0.1.2" + "@asyncapi/openapi-schema-parser" "^2.0.0" + "@asyncapi/parser" "^1.0.0-rc.1" + "@asyncapi/raml-dt-schema-parser" "^2.0.0" constate "^1.2.0" dompurify "^1.0.11" + json-schema-ref-parser "^9.0.6" markdown-it "^9.1.0" merge "^1.2.1" openapi-sampler "^1.0.0-beta.15" @@ -3074,23 +2785,23 @@ resolved "https://registry.npmjs.org/@material-icons/font/-/font-1.0.3.tgz#f722e5a69a03f20ef47d015cb69420bebeeaabe5" integrity sha512-aIRd0Z9b/HJ/O24KOaP7dNsXypMnXhpWrpLVYvQB/JesVqXYSbioYND200sR+C14a0LSCp+qWnWCnSXmN1hWGw== -"@material-ui/core@^4.9.1": - version "4.9.7" - resolved "https://registry.npmjs.org/@material-ui/core/-/core-4.9.7.tgz#0c1caf123278770f34c5d8e9ecd9e1314f87a621" - integrity sha512-RTRibZgq572GHEskMAG4sP+bt3P3XyIkv3pOTR8grZAW2rSUd6JoGZLRM4S2HkuO7wS7cAU5SpU2s1EsmTgWog== +"@material-ui/core@^4.11.0", "@material-ui/core@^4.9.1": + version "4.11.0" + resolved "https://registry.npmjs.org/@material-ui/core/-/core-4.11.0.tgz#b69b26e4553c9e53f2bfaf1053e216a0af9be15a" + integrity sha512-bYo9uIub8wGhZySHqLQ833zi4ZML+XCBE1XwJ8EuUVSpTWWG57Pm+YugQToJNFsEyiKFhPh8DPD0bgupz8n01g== dependencies: "@babel/runtime" "^7.4.4" - "@material-ui/styles" "^4.9.6" - "@material-ui/system" "^4.9.6" - "@material-ui/types" "^5.0.0" - "@material-ui/utils" "^4.9.6" + "@material-ui/styles" "^4.10.0" + "@material-ui/system" "^4.9.14" + "@material-ui/types" "^5.1.0" + "@material-ui/utils" "^4.10.2" "@types/react-transition-group" "^4.2.0" - clsx "^1.0.2" + clsx "^1.0.4" hoist-non-react-statics "^3.3.2" - popper.js "^1.14.1" + popper.js "1.16.1-lts" prop-types "^15.7.2" react-is "^16.8.0" - react-transition-group "^4.3.0" + react-transition-group "^4.4.0" "@material-ui/icons@^4.9.1": version "4.9.1" @@ -3122,16 +2833,16 @@ react-transition-group "^4.0.0" rifm "^0.7.0" -"@material-ui/styles@^4.9.6": - version "4.9.6" - resolved "https://registry.npmjs.org/@material-ui/styles/-/styles-4.9.6.tgz#924a30bf7c9b91af9c8f19c12c8573b8a4ecd085" - integrity sha512-ijgwStEkw1OZ6gCz18hkjycpr/3lKs1hYPi88O/AUn4vMuuGEGAIrqKVFq/lADmZUNF3DOFIk8LDkp7zmjPxtA== +"@material-ui/styles@^4.10.0": + version "4.10.0" + resolved "https://registry.npmjs.org/@material-ui/styles/-/styles-4.10.0.tgz#2406dc23aa358217aa8cc772e6237bd7f0544071" + integrity sha512-XPwiVTpd3rlnbfrgtEJ1eJJdFCXZkHxy8TrdieaTvwxNYj42VnnCyFzxYeNW9Lhj4V1oD8YtQ6S5Gie7bZDf7Q== dependencies: "@babel/runtime" "^7.4.4" "@emotion/hash" "^0.8.0" - "@material-ui/types" "^5.0.0" + "@material-ui/types" "^5.1.0" "@material-ui/utils" "^4.9.6" - clsx "^1.0.2" + clsx "^1.0.4" csstype "^2.5.2" hoist-non-react-statics "^3.3.2" jss "^10.0.3" @@ -3144,24 +2855,25 @@ jss-plugin-vendor-prefixer "^10.0.3" prop-types "^15.7.2" -"@material-ui/system@^4.9.6": - version "4.9.6" - resolved "https://registry.npmjs.org/@material-ui/system/-/system-4.9.6.tgz#fd060540224da4d1740da8ca6e7af288e217717e" - integrity sha512-QtfoAePyqXoZ2HUVSwGb1Ro0kucMCvVjbI0CdYIR21t0Opgfm1Oer6ni9P5lfeXA39xSt0wCierw37j+YES48Q== +"@material-ui/system@^4.9.14": + version "4.9.14" + resolved "https://registry.npmjs.org/@material-ui/system/-/system-4.9.14.tgz#4b00c48b569340cefb2036d0596b93ac6c587a5f" + integrity sha512-oQbaqfSnNlEkXEziDcJDDIy8pbvwUmZXWNqlmIwDqr/ZdCK8FuV3f4nxikUh7hvClKV2gnQ9djh5CZFTHkZj3w== dependencies: "@babel/runtime" "^7.4.4" "@material-ui/utils" "^4.9.6" + csstype "^2.5.2" prop-types "^15.7.2" -"@material-ui/types@^5.0.0": - version "5.0.0" - resolved "https://registry.npmjs.org/@material-ui/types/-/types-5.0.0.tgz#26d6259dc6b39f4c2e1e9aceff7a11e031941741" - integrity sha512-UeH2BuKkwDndtMSS0qgx1kCzSMw+ydtj0xx/XbFtxNSTlXydKwzs5gVW5ZKsFlAkwoOOQ9TIsyoCC8hq18tOwg== +"@material-ui/types@^5.1.0": + version "5.1.0" + resolved "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz#efa1c7a0b0eaa4c7c87ac0390445f0f88b0d88f2" + integrity sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A== -"@material-ui/utils@^4.7.1", "@material-ui/utils@^4.9.6": - version "4.9.6" - resolved "https://registry.npmjs.org/@material-ui/utils/-/utils-4.9.6.tgz#5f1f9f6e4df9c8b6a263293b68c94834248ff157" - integrity sha512-gqlBn0JPPTUZeAktn1rgMcy9Iczrr74ecx31tyZLVGdBGGzsxzM6PP6zeS7FuoLS6vG4hoZP7hWnOoHtkR0Kvw== +"@material-ui/utils@^4.10.2", "@material-ui/utils@^4.7.1", "@material-ui/utils@^4.9.6": + version "4.10.2" + resolved "https://registry.npmjs.org/@material-ui/utils/-/utils-4.10.2.tgz#3fd5470ca61b7341f1e0468ac8f29a70bf6df321" + integrity sha512-eg29v74P7W5r6a4tWWDAAfZldXIzfyO1am2fIsC39hdUUHm/33k6pGOKPbgDjg/U/4ifmgAePy/1OjkKN6rFRw== dependencies: "@babel/runtime" "^7.4.4" prop-types "^15.7.2" @@ -3213,6 +2925,13 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" +"@npmcli/move-file@^1.0.1": + version "1.0.1" + resolved "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.0.1.tgz#de103070dac0f48ce49cf6693c23af59c0f70464" + integrity sha512-Uv6h1sT+0DrblvIrolFtbvM1FgWm+/sy4B3pvLp67Zys+thcukzS5ekn7HsZFGpWP4Q3fYJCljbWQE/XivMRLw== + dependencies: + mkdirp "^1.0.4" + "@octokit/auth-token@^2.4.0": version "2.4.0" resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.0.tgz#b64178975218b99e4dfe948253f0673cbbb59d9f" @@ -3232,15 +2951,6 @@ before-after-hook "^2.1.0" universal-user-agent "^5.0.0" -"@octokit/endpoint@^5.5.0": - version "5.5.3" - resolved "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-5.5.3.tgz#0397d1baaca687a4c8454ba424a627699d97c978" - integrity sha512-EzKwkwcxeegYYah5ukEeAI/gYRLv2Y9U5PpIsseGSFDk+G3RbipQGBs8GuYS1TLCtQaqoO66+aQGtITPalxsNQ== - dependencies: - "@octokit/types" "^2.0.0" - is-plain-object "^3.0.0" - universal-user-agent "^5.0.0" - "@octokit/endpoint@^6.0.1": version "6.0.3" resolved "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.3.tgz#dd09b599662d7e1b66374a177ab620d8cdf73487" @@ -3291,15 +3001,15 @@ "@octokit/types" "^2.0.1" deprecation "^2.3.1" -"@octokit/plugin-rest-endpoint-methods@4.0.0": - version "4.0.0" - resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.0.0.tgz#b02a2006dda8e908c3f8ab381dd5475ef5a810a8" - integrity sha512-emS6gysz4E9BNi9IrCl7Pm4kR+Az3MmVB0/DoDCmF4U48NbYG3weKyDlgkrz6Jbl4Mu4nDx8YWZwC4HjoTdcCA== +"@octokit/plugin-rest-endpoint-methods@4.1.4": + version "4.1.4" + resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.1.4.tgz#ca60736d761b304fec02a2608caaec2d822e9835" + integrity sha512-Y2tVpSa7HjV3DGIQrQOJcReJ2JtcN9FaGr9jDa332Flro923/h3/Iu9e7Y4GilnzfLclHEh5iCQoCkHm7tWOcg== dependencies: - "@octokit/types" "^5.0.0" + "@octokit/types" "^5.4.1" deprecation "^2.3.1" -"@octokit/request-error@^1.0.1", "@octokit/request-error@^1.0.2": +"@octokit/request-error@^1.0.2": version "1.2.1" resolved "https://registry.npmjs.org/@octokit/request-error/-/request-error-1.2.1.tgz#ede0714c773f32347576c25649dc013ae6b31801" integrity sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA== @@ -3317,21 +3027,7 @@ deprecation "^2.0.0" once "^1.4.0" -"@octokit/request@^5.2.0": - version "5.3.2" - resolved "https://registry.npmjs.org/@octokit/request/-/request-5.3.2.tgz#1ca8b90a407772a1ee1ab758e7e0aced213b9883" - integrity sha512-7NPJpg19wVQy1cs2xqXjjRq/RmtSomja/VSWnptfYwuBxLdbYh2UjhGi0Wx7B1v5Iw5GKhfFDQL7jM7SSp7K2g== - dependencies: - "@octokit/endpoint" "^5.5.0" - "@octokit/request-error" "^1.0.1" - "@octokit/types" "^2.0.0" - deprecation "^2.0.0" - is-plain-object "^3.0.0" - node-fetch "^2.3.0" - once "^1.4.0" - universal-user-agent "^5.0.0" - -"@octokit/request@^5.3.0", "@octokit/request@^5.4.0": +"@octokit/request@^5.2.0", "@octokit/request@^5.3.0", "@octokit/request@^5.4.0": version "5.4.5" resolved "https://registry.npmjs.org/@octokit/request/-/request-5.4.5.tgz#8df65bd812047521f7e9db6ff118c06ba84ac10b" integrity sha512-atAs5GAGbZedvJXXdjtKljin+e2SltEs48B3naJjqWupYl2IUBbB/CJisyjbNHcKpHzb3E+OYEZ46G8eakXgQg== @@ -3368,14 +3064,14 @@ universal-user-agent "^4.0.0" "@octokit/rest@^18.0.0": - version "18.0.0" - resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.0.tgz#7f401d9ce13530ad743dfd519ae62ce49bcc0358" - integrity sha512-4G/a42lry9NFGuuECnua1R1eoKkdBYJap97jYbWDNYBOUboWcM75GJ1VIcfvwDV/pW0lMPs7CEmhHoVrSV5shg== + version "18.0.5" + resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.5.tgz#1f1498dcdc2d85d0f86b8168e4ff5779842b2742" + integrity sha512-SPKI24tQXrr1XsnaIjv2x0rl4M5eF1+hj8+vMe3d/exZ7NnL5sTe1BuFyCyJyrc+j1HkXankvgGN9zT0rwBwtg== dependencies: "@octokit/core" "^3.0.0" "@octokit/plugin-paginate-rest" "^2.2.0" "@octokit/plugin-request-log" "^1.0.0" - "@octokit/plugin-rest-endpoint-methods" "4.0.0" + "@octokit/plugin-rest-endpoint-methods" "4.1.4" "@octokit/types@^2.0.0", "@octokit/types@^2.0.1": version "2.5.0" @@ -3391,11 +3087,26 @@ dependencies: "@types/node" ">= 8" -"@open-draft/until@^1.0.0": +"@octokit/types@^5.4.1": + version "5.4.1" + resolved "https://registry.npmjs.org/@octokit/types/-/types-5.4.1.tgz#d5d5f2b70ffc0e3f89467c3db749fa87fc3b7031" + integrity sha512-OlMlSySBJoJ6uozkr/i03nO5dlYQyE05vmQNZhAh9MyO4DPBP88QlwsDVLmVjIMFssvIZB6WO0ctIGMRG+xsJQ== + dependencies: + "@types/node" ">= 8" + +"@open-draft/until@^1.0.0", "@open-draft/until@^1.0.3": version "1.0.3" resolved "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q== +"@openapi-contrib/openapi-schema-to-json-schema@^3.0.0": + version "3.0.3" + resolved "https://registry.npmjs.org/@openapi-contrib/openapi-schema-to-json-schema/-/openapi-schema-to-json-schema-3.0.3.tgz#c626eab186938f2751ee54ec68b345133bc0065c" + integrity sha512-/WX/Jos8n7CxvtWPmhlKl9qCAAW0I+VR+V4yXfQxCmB8wmjiz6lPLTGjNk5zD15qi2MGv58++hQLLdow89KdkA== + dependencies: + fast-deep-equal "^3.1.3" + lodash.clonedeep "^4.5.0" + "@panva/asn1.js@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz#dd55ae7b8129e02049f009408b97c61ccf9032f6" @@ -3454,10 +3165,10 @@ resolved "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= -"@reach/router@^1.2.1": - version "1.3.3" - resolved "https://registry.npmjs.org/@reach/router/-/router-1.3.3.tgz#58162860dce6c9449d49be86b0561b5ef46d80db" - integrity sha512-gOIAiFhWdiVGSVjukKeNKkCRBLmnORoTPyBihI/jLunICPgxdP30DroAvPQuf1eVfQbfGJQDJkwhJXsNPMnVWw== +"@reach/router@^1.3.3": + version "1.3.4" + resolved "https://registry.npmjs.org/@reach/router/-/router-1.3.4.tgz#d2574b19370a70c80480ed91f3da840136d10f8c" + integrity sha512-+mtn9wjlB9NN2CNnnC/BRYtwdKBfSyyasPYraNAyvaV1occr/5NnB4CVzjEZipNHwYebQwcndGUmpFzxAUoqSA== dependencies: create-react-context "0.3.0" invariant "^2.2.3" @@ -3465,9 +3176,9 @@ react-lifecycles-compat "^3.0.4" "@rjsf/core@^2.1.0": - version "2.2.2" - resolved "https://registry.npmjs.org/@rjsf/core/-/core-2.2.2.tgz#1ebb6fe47448998f3b54e2dea8d58de8a46014dc" - integrity sha512-4d6DHIiTJEkUq5vyl4LIxLGIYYKKnHcprf94oVchUtGQvRFjNUDFxeFQoyr90oaxcBMs2WDDcCgjcFaKVyfErg== + version "2.3.0" + resolved "https://registry.npmjs.org/@rjsf/core/-/core-2.3.0.tgz#334c73d2262ef1a8cda477e238067af7336c5599" + integrity sha512-OZKYHt9tjKhzOH4CvsPiCwepuIacqI++cNmnL2fsxh1IF+uEWGlo3NLDWhhSaBbOv9jps6a5YQcLbLtjNuSwug== dependencies: "@babel/runtime-corejs2" "^7.8.7" "@types/json-schema" "^7.0.4" @@ -3482,17 +3193,40 @@ shortid "^2.2.14" "@rjsf/material-ui@^2.1.0": - version "2.1.0" - resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.1.0.tgz#a361b125af3a383b7f671634b8d254345f9f9311" - integrity sha512-9mMttnPNP6GiP7BtZGimYcYsbWwjyviqg/PD8oxrkEtZykULXOIdC3WDMJ3nPSym8RvZsgSnB8bajCpa5iSYQQ== + version "2.3.0" + resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.3.0.tgz#a051eb4db2ad778e39933a31ba806f415dd3ba90" + integrity sha512-v/xZ4Xk18ZgBARcCe99IDqdO97GU0UC784gG8PhGGFDdy5Rbdy7ixTjHkGYttkr43PB7SX6ttohNhkKSW4nATQ== -"@roadiehq/backstage-plugin-travis-ci@^0.1.3": - version "0.1.3" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-0.1.3.tgz#597882bb6db119b9c8970f15cc506c5d70ba2c41" - integrity sha512-xTnc71VtQSp7qfiE/p92U2/FEAvsG32BHIELebYZGCIt+ZqY/fSiXZSGsOXFDlg9adTu0e8ro6HWnknXCdo2pw== +"@roadiehq/backstage-plugin-github-pull-requests@0.3.0": + version "0.3.0" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-0.3.0.tgz#465cb74b1dab6f34a5a5bf4fa57b6c1d312bc5b7" + integrity sha512-nSyMh8p3SAYj1yj5/+Po82g7aZwF5pWb+fh7bLPdf+ywl5pa+aKQxAVNCvlvgfN2WZ9gDTMbCPyWDgCfeJ/V9g== dependencies: - "@backstage/core" "0.1.1-alpha.18" - "@backstage/theme" "0.1.1-alpha.18" + "@backstage/catalog-model" "^0.1.1-alpha.16" + "@backstage/core" "^0.1.1-alpha.16" + "@backstage/core-api" "^0.1.1-alpha.16" + "@backstage/plugin-catalog" "^0.1.1-alpha.16" + "@backstage/theme" "^0.1.1-alpha.16" + "@material-ui/core" "^4.9.1" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.45" + "@octokit/rest" "^18.0.0" + "@octokit/types" "^5.0.1" + "@types/react-dom" "^16.9.8" + history "^5.0.0" + moment "^2.27.0" + react "^16.13.1" + react-dom "^16.13.1" + react-router-dom "6.0.0-beta.0" + react-use "^15.3.3" + +"@roadiehq/backstage-plugin-travis-ci@^0.1.4": + version "0.1.4" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-0.1.4.tgz#ddbb2ba5b9d47f474462ecbc8c769759578e8526" + integrity sha512-T1eJ0huhwZcRHjrDKWoAzlKBv8UbhXgrvE7wuHD7dCcmfQdln0jbn7cskOpTnVmrzGXWKQ6mDyKX3LfdEPYbAw== + dependencies: + "@backstage/core" "^0.1.1-alpha.18" + "@backstage/theme" "^0.1.1-alpha.18" "@material-ui/core" "^4.9.1" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.45" @@ -3569,10 +3303,15 @@ resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== -"@sindresorhus/is@^0.7.0": - version "0.7.0" - resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" - integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow== +"@sindresorhus/is@^2.0.0": + version "2.1.1" + resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-2.1.1.tgz#ceff6a28a5b4867c2dd4a1ba513de278ccbe8bb1" + integrity sha512-/aPsuoj/1Dw/kzhkgz+ES6TxG0zfTMGLwuK2ZG00k/iJzYHTLCE8mVU8EPqEOp/lmxPoq1C1C9RYToRKb2KEfg== + +"@sindresorhus/is@^3.1.1": + version "3.1.2" + resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-3.1.2.tgz#548650de521b344e3781fbdb0ece4aa6f729afb8" + integrity sha512-JiX9vxoKMmu8Y3Zr2RVathBL1Cdu4Nt4MuNWemt1Nc06A0RAin9c5FArkhGsyMBWfCu4zj+9b+GxtjAnE4qqLQ== "@sinonjs/commons@^1.7.0": version "1.7.1" @@ -3645,229 +3384,270 @@ glob "^7.1.4" read-pkg-up "^7.0.1" -"@storybook/addon-actions@^5.3.17": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-5.3.19.tgz#50548fa6e84bc79ad95233ce23ade4878fc7cfac" - integrity sha512-gXF29FFUgYlUoFf1DcVCmH1chg2ElaHWMmCi5h7aZe+g6fXBQw0UtEdJnYLMOqZCIiWoZyuf1ETD0RbNHPhRIw== +"@storybook/addon-actions@^6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.0.21.tgz#0de1d109d4b1eb99f644bbe84e74c25cfd2b1b6b" + integrity sha512-9y3ve+3GK1TsxQ5pxDjhB7E/XJXY+WqcSNlOX8Mb+XbS6AAgpFbkZCw1q8CGzyEUclHsQ6UK2+lo+IRGs4TLpA== dependencies: - "@storybook/addons" "5.3.19" - "@storybook/api" "5.3.19" - "@storybook/client-api" "5.3.19" - "@storybook/components" "5.3.19" - "@storybook/core-events" "5.3.19" - "@storybook/theming" "5.3.19" + "@storybook/addons" "6.0.21" + "@storybook/api" "6.0.21" + "@storybook/client-api" "6.0.21" + "@storybook/components" "6.0.21" + "@storybook/core-events" "6.0.21" + "@storybook/theming" "6.0.21" core-js "^3.0.1" - fast-deep-equal "^2.0.1" + fast-deep-equal "^3.1.1" global "^4.3.2" - polished "^3.3.1" + lodash "^4.17.15" + polished "^3.4.4" prop-types "^15.7.2" react "^16.8.3" - react-inspector "^4.0.0" - uuid "^3.3.2" + react-inspector "^5.0.1" + regenerator-runtime "^0.13.3" + ts-dedent "^1.1.1" + util-deprecate "^1.0.2" + uuid "^8.0.0" -"@storybook/addon-links@^5.3.17": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-5.3.19.tgz#3c23e886d44b56978ae254fed3bf8be54c877178" - integrity sha512-gn9u8lebREfRsyzxoDPG0O+kOf5aJ0BhzcCJGZZdqha0F6OWHhh8vJYZZvjJ/Qwze+Qt2zjrgWm+Q6+JLD8ugQ== +"@storybook/addon-links@^6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.0.21.tgz#6d4497933d560615617eaffeacec00ad8a788b01" + integrity sha512-5cRFxXS9BviDbS+DCKElr1vSafDcRhX74iIAWl/yOBUldUZvR+gX3WOZ7bO+OBSlQ1NJkt1NUAMag3aiJa4UUw== dependencies: - "@storybook/addons" "5.3.19" - "@storybook/client-logger" "5.3.19" - "@storybook/core-events" "5.3.19" + "@storybook/addons" "6.0.21" + "@storybook/client-logger" "6.0.21" + "@storybook/core-events" "6.0.21" "@storybook/csf" "0.0.1" - "@storybook/router" "5.3.19" + "@storybook/router" "6.0.21" + "@types/qs" "^6.9.0" core-js "^3.0.1" global "^4.3.2" prop-types "^15.7.2" qs "^6.6.0" - ts-dedent "^1.1.0" + regenerator-runtime "^0.13.3" + ts-dedent "^1.1.1" -"@storybook/addon-storysource@^5.3.18": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/addon-storysource/-/addon-storysource-5.3.19.tgz#ae693e88db5d220cb256a9ef4a2366c300e8d88c" - integrity sha512-W7mIAHuxYT+b1huaHCHLkBAh2MbeWmF8CxeBCFiOgZaYYQUTDEh018HJF8u2AqiWSouRhcfzhTnGxOo0hNRBgw== +"@storybook/addon-storysource@^6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/addon-storysource/-/addon-storysource-6.0.21.tgz#fce9a6de8b276239dbb49b809f3b5efd9fbcecb4" + integrity sha512-h8bu2twPfBRbWlxg8LRtCM5/r2FxWahJa0RC70qDX6eNdzDw6Xv0B8bZsVxKPWqBNQbwYPz5ui44ym53dFDM/Q== dependencies: - "@storybook/addons" "5.3.19" - "@storybook/components" "5.3.19" - "@storybook/router" "5.3.19" - "@storybook/source-loader" "5.3.19" - "@storybook/theming" "5.3.19" + "@storybook/addons" "6.0.21" + "@storybook/api" "6.0.21" + "@storybook/client-logger" "6.0.21" + "@storybook/components" "6.0.21" + "@storybook/router" "6.0.21" + "@storybook/source-loader" "6.0.21" + "@storybook/theming" "6.0.21" core-js "^3.0.1" estraverse "^4.2.0" - loader-utils "^1.2.3" - prettier "^1.16.4" + loader-utils "^2.0.0" + prettier "~2.0.5" prop-types "^15.7.2" - react-syntax-highlighter "^11.0.2" + react "^16.9.17" + react-syntax-highlighter "^12.2.1" regenerator-runtime "^0.13.3" - util-deprecate "^1.0.2" -"@storybook/addons@5.3.19", "@storybook/addons@^5.3.17": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/addons/-/addons-5.3.19.tgz#3a7010697afd6df9a41b8c8a7351d9a06ff490a4" - integrity sha512-Ky/k22p6i6FVNvs1VhuFyGvYJdcp+FgXqFgnPyY/OXJW/vPDapdElpTpHJZLFI9I2FQBDcygBPU5RXkumQ+KUQ== +"@storybook/addons@6.0.21", "@storybook/addons@^6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.0.21.tgz#bd5229652102c3aed59b78ef6920ff6b482b4d78" + integrity sha512-yDttNLc3vXqBxwK795ykgzTC6MpvuXDQuF4LHSlHZQe6wsMu1m3fljnbYdafJWdx6cNZwUblU3KYcR11PqhkPg== dependencies: - "@storybook/api" "5.3.19" - "@storybook/channels" "5.3.19" - "@storybook/client-logger" "5.3.19" - "@storybook/core-events" "5.3.19" + "@storybook/api" "6.0.21" + "@storybook/channels" "6.0.21" + "@storybook/client-logger" "6.0.21" + "@storybook/core-events" "6.0.21" + "@storybook/router" "6.0.21" + "@storybook/theming" "6.0.21" core-js "^3.0.1" global "^4.3.2" - util-deprecate "^1.0.2" + regenerator-runtime "^0.13.3" -"@storybook/api@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/api/-/api-5.3.19.tgz#77f15e9e2eee59fe1ddeaba1ef39bc34713a6297" - integrity sha512-U/VzDvhNCPmw2igvJYNNM+uwJCL+3teiL6JmuoL4/cmcqhI6IqqG9dZmMP1egoCd19wXEP7rnAfB/VcYVg41dQ== +"@storybook/api@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/api/-/api-6.0.21.tgz#a25a1eb4d07dc43500e03c856db43baba46726f1" + integrity sha512-cRRGf/KGFwYiDouTouEcDdp45N1AbYnAfvLqYZ3KuUTGZ+CiU/PN/vavkp07DQeM4FIQO8TLhzHdsLFpLT7Lkw== dependencies: - "@reach/router" "^1.2.1" - "@storybook/channels" "5.3.19" - "@storybook/client-logger" "5.3.19" - "@storybook/core-events" "5.3.19" + "@reach/router" "^1.3.3" + "@storybook/channels" "6.0.21" + "@storybook/client-logger" "6.0.21" + "@storybook/core-events" "6.0.21" "@storybook/csf" "0.0.1" - "@storybook/router" "5.3.19" - "@storybook/theming" "5.3.19" - "@types/reach__router" "^1.2.3" + "@storybook/router" "6.0.21" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.0.21" + "@types/reach__router" "^1.3.5" core-js "^3.0.1" - fast-deep-equal "^2.0.1" + fast-deep-equal "^3.1.1" global "^4.3.2" lodash "^4.17.15" memoizerific "^1.11.3" - prop-types "^15.6.2" react "^16.8.3" - semver "^6.0.0" - shallow-equal "^1.1.0" + regenerator-runtime "^0.13.3" store2 "^2.7.1" - telejson "^3.2.0" + telejson "^5.0.2" + ts-dedent "^1.1.1" util-deprecate "^1.0.2" -"@storybook/channel-postmessage@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-5.3.19.tgz#ef9fe974c2a529d89ce342ff7acf5cc22805bae9" - integrity sha512-Iq0f4NPHR0UVVFCWt0cI7Myadk4/SATXYJPT6sv95KhnLjKEeYw571WBlThfp8a9FM80887xG+eIRe93c8dleA== +"@storybook/channel-postmessage@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.0.21.tgz#97e8f43c1b66f84c7b8271e447d45d4f66d355d1" + integrity sha512-ArRnoaS+b7qpAku/SO27z/yjRDCXb37mCPYGX0ntPbiQajootUbGO7otfnjFkaP44hCEC9uDYlOfMU1hYU1N6A== dependencies: - "@storybook/channels" "5.3.19" - "@storybook/client-logger" "5.3.19" + "@storybook/channels" "6.0.21" + "@storybook/client-logger" "6.0.21" + "@storybook/core-events" "6.0.21" core-js "^3.0.1" global "^4.3.2" - telejson "^3.2.0" + qs "^6.6.0" + telejson "^5.0.2" -"@storybook/channels@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/channels/-/channels-5.3.19.tgz#65ad7cd19d70aa5eabbb2e5e39ceef5e510bcb7f" - integrity sha512-38seaeyshRGotTEZJppyYMg/Vx2zRKgFv1L6uGqkJT0LYoNSYtJhsiNFCJ2/KUJu2chAJ/j8h80bpVBVLQ/+WA== +"@storybook/channels@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.0.21.tgz#bc0951efacbaa5f8827693fba4fe7c2290b5772c" + integrity sha512-G6gjcEotSwDmOlxSmOMgsO3VhQ42RLJK7kFp6D5eg0Q6S8vsypltdT8orxdu+6+AbcBrL+5Sla8lThzaCvXsVQ== dependencies: core-js "^3.0.1" + ts-dedent "^1.1.1" + util-deprecate "^1.0.2" -"@storybook/client-api@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-5.3.19.tgz#7a5630bb8fffb92742b1773881e9004ee7fdf8e0" - integrity sha512-Dh8ZLrLH91j9Fa28Gmp0KFUvvgK348aNMrDNAUdj4m4witz/BWQ2pxz6qq9/xFVErk/GanVC05kazGElqgYCRQ== +"@storybook/client-api@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.0.21.tgz#6a652dea67d219a31d18af0e05b9f17ba6c7c316" + integrity sha512-emBXd/ml6pc3G8gP3MsR9zQsAq1zZbqof9MxB51tG/jpTXdqWQ8ce1pt1tJS8Xj0QDM072jR6wsY+mmro0GZnA== dependencies: - "@storybook/addons" "5.3.19" - "@storybook/channel-postmessage" "5.3.19" - "@storybook/channels" "5.3.19" - "@storybook/client-logger" "5.3.19" - "@storybook/core-events" "5.3.19" + "@storybook/addons" "6.0.21" + "@storybook/channel-postmessage" "6.0.21" + "@storybook/channels" "6.0.21" + "@storybook/client-logger" "6.0.21" + "@storybook/core-events" "6.0.21" "@storybook/csf" "0.0.1" - "@types/webpack-env" "^1.15.0" + "@types/qs" "^6.9.0" + "@types/webpack-env" "^1.15.2" core-js "^3.0.1" - eventemitter3 "^4.0.0" global "^4.3.2" - is-plain-object "^3.0.0" lodash "^4.17.15" memoizerific "^1.11.3" qs "^6.6.0" stable "^0.1.8" - ts-dedent "^1.1.0" + store2 "^2.7.1" + ts-dedent "^1.1.1" util-deprecate "^1.0.2" -"@storybook/client-logger@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-5.3.19.tgz#fbbd186e82102eaca1d6a5cca640271cae862921" - integrity sha512-nHftT9Ow71YgAd2/tsu79kwKk30mPuE0sGRRUHZVyCRciGFQweKNOS/6xi2Aq+WwBNNjPKNlbgxwRt1yKe1Vkg== +"@storybook/client-logger@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.0.21.tgz#20369addf9eb79fc0c85a2e0dcb48f5a1a544532" + integrity sha512-8aUEbhjXV+UMYQWukVYnp+kZafF+LD4Dm7eMo37IUZvt3VIjV1VvhxIDVJtqjk2vv0KZTepESFBkZQLmBzI9Zg== dependencies: core-js "^3.0.1" + global "^4.3.2" -"@storybook/components@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/components/-/components-5.3.19.tgz#aac1f9eea1247cc85bd93b10fca803876fb84a6b" - integrity sha512-3g23/+ktlocaHLJKISu9Neu3XKa6aYP2ctDYkRtGchSB0Q55hQsUVGO+BEVuT7Pk2D59mVCxboBjxcRoPUY4pw== +"@storybook/components@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/components/-/components-6.0.21.tgz#2f355370f993e0b7b9062094a03dffc2cdda91db" + integrity sha512-r6btqFW/rcXIU5v231EifZfdh9O0fy7bJDXwwDf8zVUgLx8JRc0VnSs3nvK3Is9HF1wZ9vjx/7Lh4rTIDZAjgg== dependencies: - "@storybook/client-logger" "5.3.19" - "@storybook/theming" "5.3.19" + "@storybook/client-logger" "6.0.21" + "@storybook/csf" "0.0.1" + "@storybook/theming" "6.0.21" + "@types/overlayscrollbars" "^1.9.0" + "@types/react-color" "^3.0.1" "@types/react-syntax-highlighter" "11.0.4" - "@types/react-textarea-autosize" "^4.3.3" core-js "^3.0.1" + fast-deep-equal "^3.1.1" global "^4.3.2" lodash "^4.17.15" markdown-to-jsx "^6.11.4" memoizerific "^1.11.3" - polished "^3.3.1" + overlayscrollbars "^1.10.2" + polished "^3.4.4" popper.js "^1.14.7" - prop-types "^15.7.2" react "^16.8.3" + react-color "^2.17.0" react-dom "^16.8.3" - react-focus-lock "^2.1.0" - react-helmet-async "^1.0.2" - react-popper-tooltip "^2.8.3" - react-syntax-highlighter "^11.0.2" - react-textarea-autosize "^7.1.0" - simplebar-react "^1.0.0-alpha.6" - ts-dedent "^1.1.0" + react-popper-tooltip "^2.11.0" + react-syntax-highlighter "^12.2.1" + react-textarea-autosize "^8.1.1" + ts-dedent "^1.1.1" -"@storybook/core-events@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-5.3.19.tgz#18020cd52e0d8ef0973a8e9622a10d5f99796f79" - integrity sha512-lh78ySqMS7pDdMJAQAe35d1I/I4yPTqp09Cq0YIYOxx9BQZhah4DZTV1QIZt22H5p2lPb5MWLkWSxBaexZnz8A== +"@storybook/core-events@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.0.21.tgz#2ce51e6d7524e7543dbb29571beac1dbeb4e5f40" + integrity sha512-p84fbPcsAhnqDhp+HJ4P8+vI2BqJus4IRoVAemLAwuPjyPElrV9UvOa/RHy1BN8Z6jXwFA+FFzfGl2kPJ3WYcA== dependencies: core-js "^3.0.1" -"@storybook/core@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/core/-/core-5.3.19.tgz#1e61f35c5148343a0c580f5d5efb77f3b4243a30" - integrity sha512-4EYzglqb1iD6x9gxtAYpRGwGP6qJGiU2UW4GiYrErEmeu6y6tkyaqW5AwGlIo9+6jAfwD0HjaK8afvjKTtmmMQ== +"@storybook/core@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/core/-/core-6.0.21.tgz#105c2b90ab27e7b478cb1b7d10e9fe5aba5e0708" + integrity sha512-/Et5NLabB12dnuPdhHDA/Q1pj0Mm2DGdL3KiLO4IC2VZeICCLGmU3/EGJBgjLK+anQ59pkclOiQ8i9eMXFiJ6A== dependencies: - "@babel/plugin-proposal-class-properties" "^7.7.0" - "@babel/plugin-proposal-object-rest-spread" "^7.6.2" - "@babel/plugin-syntax-dynamic-import" "^7.2.0" - "@babel/plugin-transform-react-constant-elements" "^7.2.0" - "@babel/preset-env" "^7.4.5" - "@storybook/addons" "5.3.19" - "@storybook/channel-postmessage" "5.3.19" - "@storybook/client-api" "5.3.19" - "@storybook/client-logger" "5.3.19" - "@storybook/core-events" "5.3.19" + "@babel/plugin-proposal-class-properties" "^7.8.3" + "@babel/plugin-proposal-decorators" "^7.8.3" + "@babel/plugin-proposal-export-default-from" "^7.8.3" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.1" + "@babel/plugin-proposal-object-rest-spread" "^7.9.6" + "@babel/plugin-proposal-optional-chaining" "^7.10.1" + "@babel/plugin-proposal-private-methods" "^7.8.3" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + "@babel/plugin-transform-arrow-functions" "^7.8.3" + "@babel/plugin-transform-block-scoping" "^7.8.3" + "@babel/plugin-transform-classes" "^7.9.5" + "@babel/plugin-transform-destructuring" "^7.9.5" + "@babel/plugin-transform-for-of" "^7.9.0" + "@babel/plugin-transform-parameters" "^7.9.5" + "@babel/plugin-transform-shorthand-properties" "^7.8.3" + "@babel/plugin-transform-spread" "^7.8.3" + "@babel/plugin-transform-template-literals" "^7.8.3" + "@babel/preset-env" "^7.9.6" + "@babel/preset-react" "^7.8.3" + "@babel/preset-typescript" "^7.9.0" + "@babel/register" "^7.10.5" + "@storybook/addons" "6.0.21" + "@storybook/api" "6.0.21" + "@storybook/channel-postmessage" "6.0.21" + "@storybook/channels" "6.0.21" + "@storybook/client-api" "6.0.21" + "@storybook/client-logger" "6.0.21" + "@storybook/components" "6.0.21" + "@storybook/core-events" "6.0.21" "@storybook/csf" "0.0.1" - "@storybook/node-logger" "5.3.19" - "@storybook/router" "5.3.19" - "@storybook/theming" "5.3.19" - "@storybook/ui" "5.3.19" + "@storybook/node-logger" "6.0.21" + "@storybook/router" "6.0.21" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.0.21" + "@storybook/ui" "6.0.21" + "@types/glob-base" "^0.3.0" + "@types/micromatch" "^4.0.1" + "@types/node-fetch" "^2.5.4" airbnb-js-shims "^2.2.1" ansi-to-html "^0.6.11" autoprefixer "^9.7.2" - babel-plugin-add-react-displayname "^0.0.5" + babel-loader "^8.0.6" babel-plugin-emotion "^10.0.20" - babel-plugin-macros "^2.7.0" + babel-plugin-macros "^2.8.0" babel-preset-minify "^0.5.0 || 0.6.0-alpha.5" + better-opn "^2.0.0" boxen "^4.1.0" case-sensitive-paths-webpack-plugin "^2.2.0" - chalk "^3.0.0" - cli-table3 "0.5.1" - commander "^4.0.1" + chalk "^4.0.0" + cli-table3 "0.6.0" + commander "^5.0.0" core-js "^3.0.1" - corejs-upgrade-webpack-plugin "^2.2.0" - css-loader "^3.0.0" + css-loader "^3.5.3" detect-port "^1.3.0" dotenv-webpack "^1.7.0" - ejs "^2.7.4" + ejs "^3.1.2" express "^4.17.0" - file-loader "^4.2.0" + file-loader "^6.0.0" file-system-cache "^1.0.5" - find-cache-dir "^3.0.0" find-up "^4.1.0" - fs-extra "^8.0.1" + fork-ts-checker-webpack-plugin "^4.1.4" + fs-extra "^9.0.0" + glob "^7.1.6" glob-base "^0.3.0" + glob-promise "^3.4.0" global "^4.3.2" - html-webpack-plugin "^4.0.0-beta.2" + html-webpack-plugin "^4.2.1" inquirer "^7.0.0" interpret "^2.0.0" ip "^1.1.5" @@ -3875,30 +3655,29 @@ lazy-universal-dotenv "^3.0.1" micromatch "^4.0.2" node-fetch "^2.6.0" - open "^7.0.0" - pnp-webpack-plugin "1.5.0" + pkg-dir "^4.2.0" + pnp-webpack-plugin "1.6.4" postcss-flexbugs-fixes "^4.1.0" postcss-loader "^3.0.0" pretty-hrtime "^1.0.3" qs "^6.6.0" - raw-loader "^3.1.0" - react-dev-utils "^9.0.0" + raw-loader "^4.0.1" + react-dev-utils "^10.0.0" regenerator-runtime "^0.13.3" - resolve "^1.11.0" resolve-from "^5.0.0" - semver "^6.0.0" serve-favicon "^2.5.0" shelljs "^0.8.3" - style-loader "^1.0.0" - terser-webpack-plugin "^2.1.2" - ts-dedent "^1.1.0" + stable "^0.1.8" + style-loader "^1.2.1" + terser-webpack-plugin "^3.0.0" + ts-dedent "^1.1.1" unfetch "^4.1.0" - url-loader "^2.0.1" + url-loader "^4.0.0" util-deprecate "^1.0.2" - webpack "^4.33.0" + webpack "^4.43.0" webpack-dev-middleware "^3.7.0" webpack-hot-middleware "^2.25.0" - webpack-virtual-modules "^0.2.0" + webpack-virtual-modules "^0.2.2" "@storybook/csf@0.0.1": version "0.0.1" @@ -3907,120 +3686,123 @@ dependencies: lodash "^4.17.15" -"@storybook/node-logger@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-5.3.19.tgz#c414e4d3781aeb06298715220012f552a36dff29" - integrity sha512-hKshig/u5Nj9fWy0OsyU04yqCxr0A9pydOHIassr4fpLAaePIN2YvqCqE2V+TxQHjZUnowSSIhbXrGt0DI5q2A== +"@storybook/node-logger@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.0.21.tgz#5b8ba589d5cca6a67c69ee8f5258755b7e1dbc08" + integrity sha512-KRBf+Fz7fgtwHdnYt70JTZbcYMZ1pQPtDyqbrFYCjwkbx5GPX5vMOozlxCIj9elseqPIsF8CKgHOW7cFHVyWYw== dependencies: "@types/npmlog" "^4.1.2" - chalk "^3.0.0" + chalk "^4.0.0" core-js "^3.0.1" npmlog "^4.1.2" pretty-hrtime "^1.0.3" - regenerator-runtime "^0.13.3" -"@storybook/react@^5.3.17": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/react/-/react-5.3.19.tgz#ad7e7a5538399e2794cdb5a1b844a2b77c10bd09" - integrity sha512-OBRUqol3YLQi/qE55x2pWkv4YpaAmmfj6/Km+7agx+og+oNQl0nnlXy7r27X/4j3ERczzURa5pJHtSjwiNaJNw== +"@storybook/react@^6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/react/-/react-6.0.21.tgz#68f8a318e9940305b06eb894896624a35a9868b0" + integrity sha512-L3PcoBJq5aK1aTaJNfwsSJ8Kxgcyk0WknN4TDqhP7a+oXmuMY1YEi96hEvQVIm0TBCkQxs61K70/T7vlilEtHg== dependencies: - "@babel/plugin-transform-react-constant-elements" "^7.6.3" "@babel/preset-flow" "^7.0.0" "@babel/preset-react" "^7.0.0" - "@storybook/addons" "5.3.19" - "@storybook/core" "5.3.19" - "@storybook/node-logger" "5.3.19" - "@svgr/webpack" "^4.0.3" - "@types/webpack-env" "^1.15.0" + "@storybook/addons" "6.0.21" + "@storybook/core" "6.0.21" + "@storybook/node-logger" "6.0.21" + "@storybook/semver" "^7.3.2" + "@svgr/webpack" "^5.4.0" + "@types/webpack-env" "^1.15.2" babel-plugin-add-react-displayname "^0.0.5" babel-plugin-named-asset-import "^0.3.1" - babel-plugin-react-docgen "^4.0.0" + babel-plugin-react-docgen "^4.1.0" core-js "^3.0.1" global "^4.3.2" lodash "^4.17.15" - mini-css-extract-plugin "^0.7.0" prop-types "^15.7.2" - react-dev-utils "^9.0.0" + react-dev-utils "^10.0.0" + react-docgen-typescript-plugin "^0.5.2" regenerator-runtime "^0.13.3" - semver "^6.0.0" - ts-dedent "^1.1.0" - webpack "^4.33.0" + ts-dedent "^1.1.1" + webpack "^4.43.0" -"@storybook/router@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/router/-/router-5.3.19.tgz#0f783b85658f99e4007f74347ad7ef17dbf7fc3a" - integrity sha512-yNClpuP7BXQlBTRf6Ggle3/R349/k6kvI5Aim4jf6X/2cFVg2pzBXDAF41imNm9PcvdxwabQLm6I48p7OvKr/w== +"@storybook/router@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.0.21.tgz#0f22261d4782c72a5a13e80cfcd8d50aed1f98c6" + integrity sha512-46SsKJfcd12lRrISnfrWhicJx8EylkgGDGohfH0n5p7inkkGOkKV8QFZoYPRKZueMXmUKpzJ0Z3HmVsLTCrCDw== dependencies: - "@reach/router" "^1.2.1" - "@storybook/csf" "0.0.1" - "@types/reach__router" "^1.2.3" + "@reach/router" "^1.3.3" + "@types/reach__router" "^1.3.5" core-js "^3.0.1" global "^4.3.2" - lodash "^4.17.15" memoizerific "^1.11.3" qs "^6.6.0" - util-deprecate "^1.0.2" -"@storybook/source-loader@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-5.3.19.tgz#ff0a00731c24c61721d8b9d84152f8542913a3b7" - integrity sha512-srSZRPgEOUse8nRVnlazweB2QGp63mPqM0uofg8zYARyaYSOzkC155ymdeiHsmiBTS3X3I0FQE4+KnwiH7iLtw== +"@storybook/semver@^7.3.2": + version "7.3.2" + resolved "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz#f3b9c44a1c9a0b933c04e66d0048fcf2fa10dac0" + integrity sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg== dependencies: - "@storybook/addons" "5.3.19" - "@storybook/client-logger" "5.3.19" + core-js "^3.6.5" + find-up "^4.1.0" + +"@storybook/source-loader@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.0.21.tgz#f4ae0fa3f3d119f9ace1d3364df21f8f7cf65bd7" + integrity sha512-Duzpz8udadR7wzH8/4F3GnMRe23oBOxTm4jBZw/T8NA+HqBtd9Y16swWw4BfwsRwfdZS4EVw3PtWgsAfoqF7ow== + dependencies: + "@storybook/addons" "6.0.21" + "@storybook/client-logger" "6.0.21" "@storybook/csf" "0.0.1" core-js "^3.0.1" estraverse "^4.2.0" global "^4.3.2" - loader-utils "^1.2.3" - prettier "^1.16.4" - prop-types "^15.7.2" + loader-utils "^2.0.0" + lodash "^4.17.15" + prettier "~2.0.5" regenerator-runtime "^0.13.3" -"@storybook/theming@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/theming/-/theming-5.3.19.tgz#177d9819bd64f7a1a6ea2f1920ffa5baf9a5f467" - integrity sha512-ecG+Rq3hc1GOzKHamYnD4wZ0PEP9nNg0mXbC3RhbxfHj+pMMCWWmx9B2Uu75SL1PTT8WcfkFO0hU/0IO84Pzlg== +"@storybook/theming@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.0.21.tgz#d56051c0b8679c2b701ce08385660ab4146cf15f" + integrity sha512-n97DfB9kG6WrV1xBGDyeQibTrh8pBBCp3dSL3UTGH+KX3C2+4sm6QHlTgyekbi5FrbFEbnuZOKAS3YbLVONsRQ== dependencies: "@emotion/core" "^10.0.20" + "@emotion/is-prop-valid" "^0.8.6" "@emotion/styled" "^10.0.17" - "@storybook/client-logger" "5.3.19" + "@storybook/client-logger" "6.0.21" core-js "^3.0.1" deep-object-diff "^1.1.0" emotion-theming "^10.0.19" global "^4.3.2" memoizerific "^1.11.3" - polished "^3.3.1" - prop-types "^15.7.2" + polished "^3.4.4" resolve-from "^5.0.0" - ts-dedent "^1.1.0" + ts-dedent "^1.1.1" -"@storybook/ui@5.3.19": - version "5.3.19" - resolved "https://registry.npmjs.org/@storybook/ui/-/ui-5.3.19.tgz#ac03b67320044a3892ee784111d4436b61874332" - integrity sha512-r0VxdWab49nm5tzwvveVDnsHIZHMR76veYOu/NHKDUZ5hnQl1LMG1YyMCFFa7KiwD/OrZxRWr6/Ma7ep9kR4Gw== +"@storybook/ui@6.0.21": + version "6.0.21" + resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.0.21.tgz#5dac2b68a30f5dba5457e0315f58977e07138968" + integrity sha512-50QYF8tHUgpVq7B7PWp7kmyf79NySWJO0piQFjHv027vV8GfbXMWVswAXwo3IfCihPlnLKe01WbsigM/9T1HCQ== dependencies: "@emotion/core" "^10.0.20" - "@storybook/addons" "5.3.19" - "@storybook/api" "5.3.19" - "@storybook/channels" "5.3.19" - "@storybook/client-logger" "5.3.19" - "@storybook/components" "5.3.19" - "@storybook/core-events" "5.3.19" - "@storybook/router" "5.3.19" - "@storybook/theming" "5.3.19" + "@storybook/addons" "6.0.21" + "@storybook/api" "6.0.21" + "@storybook/channels" "6.0.21" + "@storybook/client-logger" "6.0.21" + "@storybook/components" "6.0.21" + "@storybook/core-events" "6.0.21" + "@storybook/router" "6.0.21" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.0.21" + "@types/markdown-to-jsx" "^6.11.0" copy-to-clipboard "^3.0.8" core-js "^3.0.1" core-js-pure "^3.0.1" emotion-theming "^10.0.19" - fast-deep-equal "^2.0.1" - fuse.js "^3.4.6" + fuse.js "^3.6.1" global "^4.3.2" lodash "^4.17.15" markdown-to-jsx "^6.11.4" memoizerific "^1.11.3" - polished "^3.3.1" - prop-types "^15.7.2" + polished "^3.4.4" qs "^6.6.0" react "^16.8.3" react-dom "^16.8.3" @@ -4028,12 +3810,9 @@ react-helmet-async "^1.0.2" react-hotkeys "2.0.0" react-sizeme "^2.6.7" - regenerator-runtime "^0.13.2" + regenerator-runtime "^0.13.3" resolve-from "^5.0.0" - semver "^6.0.0" store2 "^2.7.1" - telejson "^3.2.0" - util-deprecate "^1.0.2" "@styled-system/background@^5.1.2": version "5.1.2" @@ -4141,100 +3920,46 @@ dependencies: loader-utils "^1.1.0" -"@svgr/babel-plugin-add-jsx-attribute@^4.2.0": - version "4.2.0" - resolved "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz#dadcb6218503532d6884b210e7f3c502caaa44b1" - integrity sha512-j7KnilGyZzYr/jhcrSYS3FGWMZVaqyCG0vzMCwzvei0coIkczuYMcniK07nI0aHJINciujjH11T72ICW5eL5Ig== - "@svgr/babel-plugin-add-jsx-attribute@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz#81ef61947bb268eb9d50523446f9c638fb355906" integrity sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg== -"@svgr/babel-plugin-remove-jsx-attribute@^4.2.0": - version "4.2.0" - resolved "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-4.2.0.tgz#297550b9a8c0c7337bea12bdfc8a80bb66f85abc" - integrity sha512-3XHLtJ+HbRCH4n28S7y/yZoEQnRpl0tvTZQsHqvaeNXPra+6vE5tbRliH3ox1yZYPCxrlqaJT/Mg+75GpDKlvQ== - "@svgr/babel-plugin-remove-jsx-attribute@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz#6b2c770c95c874654fd5e1d5ef475b78a0a962ef" integrity sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg== -"@svgr/babel-plugin-remove-jsx-empty-expression@^4.2.0": - version "4.2.0" - resolved "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-4.2.0.tgz#c196302f3e68eab6a05e98af9ca8570bc13131c7" - integrity sha512-yTr2iLdf6oEuUE9MsRdvt0NmdpMBAkgK8Bjhl6epb+eQWk6abBaX3d65UZ3E3FWaOwePyUgNyNCMVG61gGCQ7w== - "@svgr/babel-plugin-remove-jsx-empty-expression@^5.0.1": version "5.0.1" resolved "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz#25621a8915ed7ad70da6cea3d0a6dbc2ea933efd" integrity sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA== -"@svgr/babel-plugin-replace-jsx-attribute-value@^4.2.0": - version "4.2.0" - resolved "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-4.2.0.tgz#310ec0775de808a6a2e4fd4268c245fd734c1165" - integrity sha512-U9m870Kqm0ko8beHawRXLGLvSi/ZMrl89gJ5BNcT452fAjtF2p4uRzXkdzvGJJJYBgx7BmqlDjBN/eCp5AAX2w== - "@svgr/babel-plugin-replace-jsx-attribute-value@^5.0.1": version "5.0.1" resolved "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz#0b221fc57f9fcd10e91fe219e2cd0dd03145a897" integrity sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ== -"@svgr/babel-plugin-svg-dynamic-title@^4.3.3": - version "4.3.3" - resolved "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-4.3.3.tgz#2cdedd747e5b1b29ed4c241e46256aac8110dd93" - integrity sha512-w3Be6xUNdwgParsvxkkeZb545VhXEwjGMwExMVBIdPQJeyMQHqm9Msnb2a1teHBqUYL66qtwfhNkbj1iarCG7w== - "@svgr/babel-plugin-svg-dynamic-title@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz#139b546dd0c3186b6e5db4fefc26cb0baea729d7" integrity sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg== -"@svgr/babel-plugin-svg-em-dimensions@^4.2.0": - version "4.2.0" - resolved "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-4.2.0.tgz#9a94791c9a288108d20a9d2cc64cac820f141391" - integrity sha512-C0Uy+BHolCHGOZ8Dnr1zXy/KgpBOkEUYY9kI/HseHVPeMbluaX3CijJr7D4C5uR8zrc1T64nnq/k63ydQuGt4w== - "@svgr/babel-plugin-svg-em-dimensions@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz#6543f69526632a133ce5cabab965deeaea2234a0" integrity sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw== -"@svgr/babel-plugin-transform-react-native-svg@^4.2.0": - version "4.2.0" - resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-4.2.0.tgz#151487322843359a1ca86b21a3815fd21a88b717" - integrity sha512-7YvynOpZDpCOUoIVlaaOUU87J4Z6RdD6spYN4eUb5tfPoKGSF9OG2NuhgYnq4jSkAxcpMaXWPf1cePkzmqTPNw== - "@svgr/babel-plugin-transform-react-native-svg@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz#00bf9a7a73f1cad3948cdab1f8dfb774750f8c80" integrity sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q== -"@svgr/babel-plugin-transform-svg-component@^4.2.0": - version "4.2.0" - resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-4.2.0.tgz#5f1e2f886b2c85c67e76da42f0f6be1b1767b697" - integrity sha512-hYfYuZhQPCBVotABsXKSCfel2slf/yvJY8heTVX1PCTaq/IgASq1IyxPPKJ0chWREEKewIU/JMSsIGBtK1KKxw== - "@svgr/babel-plugin-transform-svg-component@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.4.0.tgz#a2212b4d018e6075a058bb7e220a66959ef7a03c" integrity sha512-zLl4Fl3NvKxxjWNkqEcpdSOpQ3LGVH2BNFQ6vjaK6sFo2IrSznrhURIPI0HAphKiiIwNYjAfE0TNoQDSZv0U9A== -"@svgr/babel-preset@^4.3.3": - version "4.3.3" - resolved "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-4.3.3.tgz#a75d8c2f202ac0e5774e6bfc165d028b39a1316c" - integrity sha512-6PG80tdz4eAlYUN3g5GZiUjg2FMcp+Wn6rtnz5WJG9ITGEF1pmFdzq02597Hn0OmnQuCVaBYQE1OVFAnwOl+0A== - dependencies: - "@svgr/babel-plugin-add-jsx-attribute" "^4.2.0" - "@svgr/babel-plugin-remove-jsx-attribute" "^4.2.0" - "@svgr/babel-plugin-remove-jsx-empty-expression" "^4.2.0" - "@svgr/babel-plugin-replace-jsx-attribute-value" "^4.2.0" - "@svgr/babel-plugin-svg-dynamic-title" "^4.3.3" - "@svgr/babel-plugin-svg-em-dimensions" "^4.2.0" - "@svgr/babel-plugin-transform-react-native-svg" "^4.2.0" - "@svgr/babel-plugin-transform-svg-component" "^4.2.0" - "@svgr/babel-preset@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.4.0.tgz#da21854643e1c4ad2279239baa7d5a8b128c1f15" @@ -4249,15 +3974,6 @@ "@svgr/babel-plugin-transform-react-native-svg" "^5.4.0" "@svgr/babel-plugin-transform-svg-component" "^5.4.0" -"@svgr/core@^4.3.3": - version "4.3.3" - resolved "https://registry.npmjs.org/@svgr/core/-/core-4.3.3.tgz#b37b89d5b757dc66e8c74156d00c368338d24293" - integrity sha512-qNuGF1QON1626UCaZamWt5yedpgOytvLj5BQZe2j1k1B8DUG4OyugZyfEwBeXozCUwhLEpsrgPrE+eCu4fY17w== - dependencies: - "@svgr/plugin-jsx" "^4.3.3" - camelcase "^5.3.1" - cosmiconfig "^5.2.1" - "@svgr/core@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/core/-/core-5.4.0.tgz#655378ee43679eb94fee3d4e1976e38252dff8e7" @@ -4267,13 +3983,6 @@ camelcase "^6.0.0" cosmiconfig "^6.0.0" -"@svgr/hast-util-to-babel-ast@^4.3.2": - version "4.3.2" - resolved "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-4.3.2.tgz#1d5a082f7b929ef8f1f578950238f630e14532b8" - integrity sha512-JioXclZGhFIDL3ddn4Kiq8qEqYM2PyDKV0aYno8+IXTLuYt6TOgHUbUAAFvqtb0Xn37NwP0BTHglejFoYr8RZg== - dependencies: - "@babel/types" "^7.4.4" - "@svgr/hast-util-to-babel-ast@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.4.0.tgz#bb5d002e428f510aa5b53ec0a02377a95b367715" @@ -4281,17 +3990,7 @@ dependencies: "@babel/types" "^7.9.5" -"@svgr/plugin-jsx@4.3.x", "@svgr/plugin-jsx@^4.3.3": - version "4.3.3" - resolved "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-4.3.3.tgz#e2ba913dbdfbe85252a34db101abc7ebd50992fa" - integrity sha512-cLOCSpNWQnDB1/v+SUENHH7a0XY09bfuMKdq9+gYvtuwzC2rU4I0wKGFEp1i24holdQdwodCtDQdFtJiTCWc+w== - dependencies: - "@babel/core" "^7.4.5" - "@svgr/babel-preset" "^4.3.3" - "@svgr/hast-util-to-babel-ast" "^4.3.2" - svg-parser "^2.0.0" - -"@svgr/plugin-jsx@^5.4.0": +"@svgr/plugin-jsx@5.4.x", "@svgr/plugin-jsx@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.4.0.tgz#ab47504c55615833c6db70fca2d7e489f509787c" integrity sha512-SGzO4JZQ2HvGRKDzRga9YFSqOqaNrgLlQVaGvpZ2Iht2gwRp/tq+18Pvv9kS9ZqOMYgyix2LLxZMY1LOe9NPqw== @@ -4301,16 +4000,7 @@ "@svgr/hast-util-to-babel-ast" "^5.4.0" svg-parser "^2.0.2" -"@svgr/plugin-svgo@4.3.x", "@svgr/plugin-svgo@^4.3.1": - version "4.3.1" - resolved "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-4.3.1.tgz#daac0a3d872e3f55935c6588dd370336865e9e32" - integrity sha512-PrMtEDUWjX3Ea65JsVCwTIXuSqa3CG9px+DluF1/eo9mlDrgrtFE7NE/DjdhjJgSM9wenlVBzkzneSIUgfUI/w== - dependencies: - cosmiconfig "^5.2.1" - merge-deep "^3.0.2" - svgo "^1.2.2" - -"@svgr/plugin-svgo@^5.4.0": +"@svgr/plugin-svgo@5.4.x", "@svgr/plugin-svgo@^5.4.0": version "5.4.0" resolved "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.4.0.tgz#45d9800b7099a6f7b4d85ebac89ab9abe8592f64" integrity sha512-3Cgv3aYi1l6SHyzArV9C36yo4kgwVdF3zPQUC6/aCDUeXAofDYwE5kk3e3oT5ZO2a0N3lB+lLGvipBG6lnG8EA== @@ -4333,19 +4023,19 @@ "@svgr/plugin-svgo" "^5.4.0" rollup-pluginutils "^2.8.2" -"@svgr/webpack@4.3.x", "@svgr/webpack@^4.0.3": - version "4.3.3" - resolved "https://registry.npmjs.org/@svgr/webpack/-/webpack-4.3.3.tgz#13cc2423bf3dff2d494f16b17eb7eacb86895017" - integrity sha512-bjnWolZ6KVsHhgyCoYRFmbd26p8XVbulCzSG53BDQqAr+JOAderYK7CuYrB3bDjHJuF6LJ7Wrr42+goLRV9qIg== +"@svgr/webpack@5.4.x", "@svgr/webpack@^5.4.0": + version "5.4.0" + resolved "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.4.0.tgz#b68bc86e29cf007292b96ced65f80971175632e0" + integrity sha512-LjepnS/BSAvelnOnnzr6Gg0GcpLmnZ9ThGFK5WJtm1xOqdBE/1IACZU7MMdVzjyUkfFqGz87eRE4hFaSLiUwYg== dependencies: - "@babel/core" "^7.4.5" - "@babel/plugin-transform-react-constant-elements" "^7.0.0" - "@babel/preset-env" "^7.4.5" - "@babel/preset-react" "^7.0.0" - "@svgr/core" "^4.3.3" - "@svgr/plugin-jsx" "^4.3.3" - "@svgr/plugin-svgo" "^4.3.1" - loader-utils "^1.2.3" + "@babel/core" "^7.9.0" + "@babel/plugin-transform-react-constant-elements" "^7.9.0" + "@babel/preset-env" "^7.9.5" + "@babel/preset-react" "^7.9.4" + "@svgr/core" "^5.4.0" + "@svgr/plugin-jsx" "^5.4.0" + "@svgr/plugin-svgo" "^5.4.0" + loader-utils "^2.0.0" "@szmarczak/http-timer@^1.1.2": version "1.1.2" @@ -4354,24 +4044,32 @@ dependencies: defer-to-connect "^1.0.1" -"@testing-library/cypress@^6.0.0": - version "6.0.0" - resolved "https://registry.npmjs.org/@testing-library/cypress/-/cypress-6.0.0.tgz#935f7716e0e495f02fd753a42621e4d350097dce" - integrity sha512-vWPQtPsIDk5STOH2XdJbJoYq9gxOSAItP0ail+MlylK230zNkf3ODKd6eqWnDdruuqrhTF3CyqvPNMA8Xks/UQ== +"@szmarczak/http-timer@^4.0.0", "@szmarczak/http-timer@^4.0.5": + version "4.0.5" + resolved "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz#bfbd50211e9dfa51ba07da58a14cdfd333205152" + integrity sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ== dependencies: - "@babel/runtime" "^7.8.7" - "@testing-library/dom" "^7.0.2" - "@types/testing-library__cypress" "^5.0.3" + defer-to-connect "^2.0.0" -"@testing-library/dom@^7.0.2", "@testing-library/dom@^7.17.1": - version "7.17.2" - resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-7.17.2.tgz#d062e41336b885107bca8ffc7022eaf37cccaee1" - integrity sha512-TQAoIzoI9g64oNVJ+13i4cCh/DQp/n7fJOyMqlFB1oQVusPtSgiPImyb52CwtvPO7J0Im0+/4YcmxU9a+cVxxw== +"@testing-library/cypress@^6.0.0": + version "6.0.1" + resolved "https://registry.npmjs.org/@testing-library/cypress/-/cypress-6.0.1.tgz#c924a69617db6a403c498abcb63759b79318fc76" + integrity sha512-hcPu2OnVuSTX1yDubEe7TBRD6mP2VgyopP1WTBIrJP79INPZdgOAX+TMNH68uZ/r5b4C7100IB4hjPIEQ+/Xog== + dependencies: + "@babel/runtime" "^7.11.2" + "@testing-library/dom" "^7.22.2" + "@types/testing-library__cypress" "^5.0.6" + +"@testing-library/dom@^7.11.0", "@testing-library/dom@^7.17.1", "@testing-library/dom@^7.22.2": + version "7.23.0" + resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-7.23.0.tgz#c54c0fa53705ad867bcefb52fc0c96487fbc10f6" + integrity sha512-H5m090auYH+obdZmsaYLrSWC5OauWD2CvNbz88KBxQJoXgkJzbU0DpAG8BS7Evj5WqCC3nAAKrLS6vw0ljUYLg== dependencies: "@babel/runtime" "^7.10.3" + "@types/aria-query" "^4.2.0" aria-query "^4.2.2" - dom-accessibility-api "^0.4.5" - pretty-format "^25.5.0" + dom-accessibility-api "^0.5.1" + pretty-format "^26.4.2" "@testing-library/jest-dom@^5.10.1": version "5.10.1" @@ -4478,10 +4176,15 @@ resolved "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA== -"@types/babel__core@^7.1.7": - version "7.1.7" - resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.7.tgz#1dacad8840364a57c98d0dd4855c6dd3752c6b89" - integrity sha512-RL62NqSFPCDK2FM1pSDH0scHpJvsXtZNiYlMB73DgPBaG1E38ZYVL+ei5EkWRbr+KC4YNiAUNBnRj+bgwpgjMw== +"@types/aria-query@^4.2.0": + version "4.2.0" + resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0" + integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A== + +"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": + version "7.1.9" + resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d" + integrity sha512-sY2RsIJ5rpER1u3/aQ8OFSI7qGIy8o1NEEbgb2UaJcvOtXOMpd39ko723NBpjQFg9SIX7TXtjejZVGeIMLhoOw== dependencies: "@babel/parser" "^7.1.0" "@babel/types" "^7.0.0" @@ -4519,12 +4222,20 @@ "@types/connect" "*" "@types/node" "*" -"@types/cheerio@^0.22.8": - version "0.22.21" - resolved "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.21.tgz#5e37887de309ba11b2e19a6e14cad7874b31a8a3" - integrity sha512-aGI3DfswwqgKPiEOTaiHV2ZPC9KEhprpgEbJnv0fZl3SGX0cGgEva1126dGrMC6AJM6v/aihlUgJn9M5DbDZ/Q== +"@types/braces@*": + version "3.0.0" + resolved "https://registry.npmjs.org/@types/braces/-/braces-3.0.0.tgz#7da1c0d44ff1c7eb660a36ec078ea61ba7eb42cb" + integrity sha512-TbH79tcyi9FHwbyboOKeRachRq63mSuWYXOflsNO9ZyE5ClQ/JaozNKl+aWUq87qPNsXasXxi2AbgfwIJ+8GQw== + +"@types/cacheable-request@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.1.tgz#5d22f3dded1fd3a84c0bbeb5039a7419c2c91976" + integrity sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ== dependencies: + "@types/http-cache-semantics" "*" + "@types/keyv" "*" "@types/node" "*" + "@types/responselike" "*" "@types/classnames@^2.2.9": version "2.2.10" @@ -4538,10 +4249,10 @@ dependencies: "@types/node" "*" -"@types/codemirror@^0.0.96": - version "0.0.96" - resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.96.tgz#73b52e784a246cebef31d544fef45ea764de5bad" - integrity sha512-GTswEV26Bl1byRxpD3sKd1rT2AISr0rK9ImlJgEzfvqhcVWeu4xQKFQI6UgSC95NT5swNG4st/oRMeGVZgPj9w== +"@types/codemirror@^0.0.97": + version "0.0.97" + resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.97.tgz#6f2d8266b7f1b34aacfe8c77221fafe324c3d081" + integrity sha512-n5d7o9nWhC49DjfhsxANP7naWSeTzrjXASkUDQh7626sM4zK9XP2EVcHp1IcCf/IPV6c7ORzDUDF3Bkt231VKg== dependencies: "@types/tern" "*" @@ -4603,6 +4314,11 @@ resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== +"@types/cookie@^0.4.0": + version "0.4.0" + resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.0.tgz#14f854c0f93d326e39da6e3b6f34f7d37513d108" + integrity sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg== + "@types/cookiejar@*": version "2.1.1" resolved "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz#90b68446364baf9efd8e8349bb36bd3852b75b80" @@ -4642,14 +4358,7 @@ resolved "https://registry.npmjs.org/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c" integrity sha512-mIenTfsIe586/yzsyfql69KRnA75S8SVXQbTLpDejRrjH0QSJcpu3AUOi/Vjnt9IOsXKxPhJfGpQUNMueIU1fQ== -"@types/dockerode@^2.5.32": - version "2.5.32" - resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.32.tgz#52d3628f605f8ea65202541c59a8a6dd166384fd" - integrity sha512-TfaGOoOHxsjkWRj2sPoQ3FLmTC5mVMhZ4kzZy13U7mjtIDoloE4e7AMj5jPLbffWB6Csy5DF5e0lC9M+tnKz/A== - dependencies: - "@types/node" "*" - -"@types/dockerode@^2.5.34": +"@types/dockerode@^2.5.32", "@types/dockerode@^2.5.34": version "2.5.34" resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.34.tgz#9adb884f7cc6c012a6eb4b2ad794cc5d01439959" integrity sha512-LcbLGcvcBwBAvjH9UrUI+4qotY+A5WCer5r43DR5XHv2ZIEByNXFdPLo1XxR+v/BjkGjlggW8qUiXuVEhqfkpA== @@ -4679,11 +4388,6 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== -"@types/events@*": - version "3.0.0" - resolved "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" - integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== - "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.5": version "4.17.9" resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.9.tgz#2d7b34dcfd25ec663c25c85d76608f8b249667f1" @@ -4727,19 +4431,23 @@ resolved "https://registry.npmjs.org/@types/github-slugger/-/github-slugger-1.3.0.tgz#16ab393b30d8ae2a111ac748a015ac05a1fc5524" integrity sha512-J/rMZa7RqiH/rT29TEVZO4nBoDP9XJOjnbbIofg7GQKs4JIduEO3WLpte+6WeUz/TcrXKlY+bM7FYrp8yFB+3g== -"@types/glob@^7.1.1": - version "7.1.1" - resolved "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" - integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w== +"@types/glob-base@^0.3.0": + version "0.3.0" + resolved "https://registry.npmjs.org/@types/glob-base/-/glob-base-0.3.0.tgz#a581d688347e10e50dd7c17d6f2880a10354319d" + integrity sha1-pYHWiDR+EOUN18F9byiAoQNUMZ0= + +"@types/glob@*", "@types/glob@^7.1.1": + version "7.1.3" + resolved "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183" + integrity sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w== dependencies: - "@types/events" "*" "@types/minimatch" "*" "@types/node" "*" "@types/google-protobuf@^3.7.2": - version "3.7.2" - resolved "https://registry.npmjs.org/@types/google-protobuf/-/google-protobuf-3.7.2.tgz#cd8a360c193ce4d672575a20a79f49ba036d38d2" - integrity sha512-ifFemzjNchFBCtHS6bZNhSZCBu7tbtOe0e8qY0z2J4HtFXmPJjm6fXSaQsTG7yhShBEZtt2oP/bkwu5k+emlkQ== + version "3.7.3" + resolved "https://registry.npmjs.org/@types/google-protobuf/-/google-protobuf-3.7.3.tgz#429512e541bbd777f2c867692e6335ee08d1f6d4" + integrity sha512-FRwj40euE2bYkG+0X5w2nEA8yAzgJRcEa7RBd0Gsdkb9/tPM2pctVVAvnOUTbcXo2VmIHPo0Ae94Gl9vRHfKzg== "@types/graceful-fs@^4.1.2": version "4.1.3" @@ -4758,10 +4466,10 @@ "@types/koa" "*" graphql "^14.5.3" -"@types/helmet@^0.0.47": - version "0.0.47" - resolved "https://registry.npmjs.org/@types/helmet/-/helmet-0.0.47.tgz#ec5161541b649142205b7c558bca14801c5ce129" - integrity sha512-TcHA/djjdUtrMtq/QAayVLrsgjNNZ1Uhtz0KhfH01mrmjH44E54DA1A0HNbwW0H/NBFqV+tGMo85ACuEhMXcdg== +"@types/helmet@^0.0.48": + version "0.0.48" + resolved "https://registry.npmjs.org/@types/helmet/-/helmet-0.0.48.tgz#e754399d2f4672ba63962e8490efd3edd31d9799" + integrity sha512-C7MpnvSDrunS1q2Oy1VWCY7CDWHozqSnM8P4tFeRTuzwqni+PYOjEredwcqWG+kLpYcgLsgcY3orHB54gbx2Jw== dependencies: "@types/express" "*" @@ -4798,12 +4506,17 @@ resolved "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b" integrity sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ== +"@types/http-cache-semantics@*": + version "4.0.0" + resolved "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz#9140779736aa2655635ee756e2467d787cfe8a2a" + integrity sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A== + "@types/http-errors@^1.6.3": version "1.8.0" resolved "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.8.0.tgz#682477dbbbd07cd032731cb3b0e7eaee3d026b69" integrity sha512-2aoSC4UUbHDj2uCsCxcG/vRMXey/m17bC7UwitVm5hn22nI8O8Y9iDpA76Orc+DWkQ4zZrOKEshCqR/jSuXAHA== -"@types/http-proxy-middleware@*": +"@types/http-proxy-middleware@*", "@types/http-proxy-middleware@^0.19.3": version "0.19.3" resolved "https://registry.npmjs.org/@types/http-proxy-middleware/-/http-proxy-middleware-0.19.3.tgz#b2eb96fbc0f9ac7250b5d9c4c53aade049497d03" integrity sha512-lnBTx6HCOUeIJMLbI/LaL5EmdKLhczJY5oeXZpX/cXE4rRqb3RmV7VcMpiEfYkmTjipv3h7IAyIINe4plEv7cA== @@ -4819,10 +4532,10 @@ dependencies: "@types/node" "*" -"@types/inquirer@^6.5.0": - version "6.5.0" - resolved "https://registry.npmjs.org/@types/inquirer/-/inquirer-6.5.0.tgz#b83b0bf30b88b8be7246d40e51d32fe9d10e09be" - integrity sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw== +"@types/inquirer@^7.3.1": + version "7.3.1" + resolved "https://registry.npmjs.org/@types/inquirer/-/inquirer-7.3.1.tgz#1f231224e7df11ccfaf4cf9acbcc3b935fea292d" + integrity sha512-osD38QVIfcdgsPCT0V3lD7eH0OFurX71Jft18bZrsVQWVRt6TuxRzlr0GJLrxoHZR2V5ph7/qP8se/dcnI7o0g== dependencies: "@types/through" "*" rxjs "^6.4.0" @@ -4852,6 +4565,13 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-report" "*" +"@types/istanbul-reports@^3.0.0": + version "3.0.0" + resolved "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz#508b13aa344fa4976234e75dddcc34925737d821" + integrity sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA== + dependencies: + "@types/istanbul-lib-report" "*" + "@types/jest@*", "@types/jest@^26.0.7": version "26.0.9" resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.9.tgz#0543b57da5f0cd949c5f423a00c56c492289c989" @@ -4873,9 +4593,9 @@ integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw== "@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5": - version "7.0.5" - resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd" - integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ== + version "7.0.6" + resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0" + integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw== "@types/json5@^0.0.29": version "0.0.29" @@ -4892,6 +4612,13 @@ resolved "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72" integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw== +"@types/keyv@*", "@types/keyv@^3.1.1": + version "3.1.1" + resolved "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.1.tgz#e45a45324fca9dab716ab1230ee249c9fb52cfa7" + integrity sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw== + dependencies: + "@types/node" "*" + "@types/koa-compose@*": version "3.2.5" resolved "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d" @@ -4913,15 +4640,29 @@ "@types/node" "*" "@types/lodash@^4.14.151": - version "4.14.159" - resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.159.tgz#61089719dc6fdd9c5cb46efc827f2571d1517065" - integrity sha512-gF7A72f7WQN33DpqOWw9geApQPh4M3PxluMtaHxWHXEGSN12/WbcEk/eNSqWNQcQhF66VSZ06vCF94CrHwXJDg== + version "4.14.161" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.161.tgz#a21ca0777dabc6e4f44f3d07f37b765f54188b18" + integrity sha512-EP6O3Jkr7bXvZZSZYlsgt5DIjiGr0dXP1/jVEwVLTFgg0d+3lWVQkRavYVQszV7dYUwvg0B8R0MBDpcmXg7XIA== "@types/long@^4.0.0": version "4.0.1" resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== +"@types/markdown-to-jsx@^6.11.0": + version "6.11.2" + resolved "https://registry.npmjs.org/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.2.tgz#05d1aaffbf15be7be12c70535fa4fed65cc7c64f" + integrity sha512-ESuCu8Bk7jpTZ3YPdMW1+6wUj13F5N15vXfc7BuUAN0eCp0lrvVL9nzOTzoqvbRzXMciuqXr1KrHt3xQAhfwPA== + dependencies: + "@types/react" "*" + +"@types/micromatch@^4.0.1": + version "4.0.1" + resolved "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.1.tgz#9381449dd659fc3823fd2a4190ceacc985083bc7" + integrity sha512-my6fLBvpY70KattTNzYOK6KU1oR1+UCz9ug/JbcF5UrEmeCt9P7DV2t7L8+t18mMPINqGQCE4O8PLOPbI84gxw== + dependencies: + "@types/braces" "*" + "@types/mime@*": version "2.0.1" resolved "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" @@ -4946,6 +4687,13 @@ dependencies: "@types/node" "*" +"@types/mock-fs@^4.10.0": + version "4.10.0" + resolved "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.10.0.tgz#460061b186993d76856f669d5317cda8a007c24b" + integrity sha512-FQ5alSzmHMmliqcL36JqIA4Yyn9jyJKvRSGV3mvPh108VFatX7naJDzSG4fnFQNZFq9dIx0Dzoe6ddflMB2Xkg== + dependencies: + "@types/node" "*" + "@types/morgan@^1.9.0": version "1.9.1" resolved "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.1.tgz#6457872df95647c1dbc6b3741e8146b71ece74bf" @@ -4953,7 +4701,7 @@ dependencies: "@types/node" "*" -"@types/node-fetch@2.5.7", "@types/node-fetch@^2.5.7": +"@types/node-fetch@2.5.7", "@types/node-fetch@^2.5.4", "@types/node-fetch@^2.5.7": version "2.5.7" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== @@ -4981,10 +4729,10 @@ resolved "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz#fe1cc3aa465a3ea6858b793fd380b66c39919766" integrity sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw== -"@types/nodegit@0.26.7": - version "0.26.7" - resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.7.tgz#3fc8f5108dcabddd6238509748c0e812ba02b9b7" - integrity sha512-qVwQupq4To/wkRtUnaiwbNhuRiVJShOG9CYIWiGdKQRkvWijtHmEnrC1KJHOVrzK04sMdHRClyecrfmN17VMFQ== +"@types/nodegit@0.26.8": + version "0.26.8" + resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.8.tgz#81a79307fbdd1cf028a8ec7cbc94020bac54be6f" + integrity sha512-CwcynPazar6cJxjn8PG2RIujNgFvH2pAoA/bBBvPTUrONVzc9hj0DRMYzioy7zI/+e8TfJJLP+DK26mEU0eErg== dependencies: "@types/node" "*" @@ -5012,6 +4760,11 @@ dependencies: ora "*" +"@types/overlayscrollbars@^1.9.0": + version "1.12.0" + resolved "https://registry.npmjs.org/@types/overlayscrollbars/-/overlayscrollbars-1.12.0.tgz#98456caceca8ad73bd5bb572632a585074e70764" + integrity sha512-h/pScHNKi4mb+TrJGDon8Yb06ujFG0mSg12wIO0sWMUF3dQIe2ExRRdNRviaNt9IjxIiOfnRr7FsQAdHwK4sMg== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -5035,6 +4788,13 @@ "@types/passport" "*" "@types/passport-oauth2" "*" +"@types/passport-microsoft@^0.0.0": + version "0.0.0" + resolved "https://registry.npmjs.org/@types/passport-microsoft/-/passport-microsoft-0.0.0.tgz#ba71bccdd793711239d6b02e8d5953c21abc1c8d" + integrity sha512-jfkltRosn+P/+RoFMTl+mCyBTgPTFhjDEF832j7fmlYpuf+5yuzPLz7Rm5XMKN/Gqpro6myCyGPTuCc4yBQ2jQ== + dependencies: + "@types/passport-oauth2" "*" + "@types/passport-oauth2@*": version "1.4.9" resolved "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.9.tgz#134007c4b505a82548c9cb19094c5baeb2205c92" @@ -5074,7 +4834,7 @@ resolved "https://registry.npmjs.org/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== -"@types/qs@*": +"@types/qs@*", "@types/qs@^6.9.0": version "6.9.4" resolved "https://registry.npmjs.org/@types/qs/-/qs-6.9.4.tgz#a59e851c1ba16c0513ea123830dd639a0a15cb6a" integrity sha512-+wYo+L6ZF6BMoEjtf8zB2esQsqdV6WsjRK/GP9WOgLPrq87PbNWgIxS76dS5uvl/QXtHGakZmwTznIfcPXcKlQ== @@ -5084,14 +4844,22 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== -"@types/reach__router@^1.2.3": - version "1.3.1" - resolved "https://registry.npmjs.org/@types/reach__router/-/reach__router-1.3.1.tgz#ca8b431acb12bb897d2b806f6fdd815f056d6d02" - integrity sha512-E51ntVeunnxofXmOoPFiOvElHWf+jBEs3B56gGx7XhPHOkJdjWxWDY4V1AsUiwhtOCXPM7atFy30wj7glyv2Fg== +"@types/reach__router@^1.3.5": + version "1.3.5" + resolved "https://registry.npmjs.org/@types/reach__router/-/reach__router-1.3.5.tgz#14e1e981cccd3a5e50dc9e969a72de0b9d472f6d" + integrity sha512-h0NbqXN/tJuBY/xggZSej1SKQEstbHO7J/omt1tYoFGmj3YXOodZKbbqD4mNDh7zvEGYd7YFrac1LTtAr3xsYQ== dependencies: "@types/history" "*" "@types/react" "*" +"@types/react-color@^3.0.1": + version "3.0.4" + resolved "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.4.tgz#c63daf012ad067ac0127bdd86725f079d02082bd" + integrity sha512-EswbYJDF1kkrx93/YU+BbBtb46CCtDMvTiGmcOa/c5PETnwTiSWoseJ1oSWeRl/4rUXkhME9bVURvvPg0W5YQw== + dependencies: + "@types/react" "*" + "@types/reactcss" "*" + "@types/react-dev-utils@^9.0.4": version "9.0.4" resolved "https://registry.npmjs.org/@types/react-dev-utils/-/react-dev-utils-9.0.4.tgz#3e4bee79b7536777cef219427ab1d38adc24f3f2" @@ -5110,10 +4878,10 @@ dependencies: "@types/react" "*" -"@types/react-helmet@^5.0.15": - version "5.0.15" - resolved "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-5.0.15.tgz#af0370617307e9f062c6aee0f1f5588224ce646e" - integrity sha512-CCjqvecDJTXRrHG8aTc2YECcQCl26za/q+NaBRvy/wtm0Uh38koM2dpv2bG1xJV4ckz3t1lm2/5KU6nt2s9BWg== +"@types/react-helmet@^6.1.0": + version "6.1.0" + resolved "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.0.tgz#af586ed685f4905e2adc7462d1d65ace52beee7a" + integrity sha512-PYRoU1XJFOzQ3BHvWL1T8iDNbRjdMDJMT5hFmZKGbsq09kbSqJy61uwEpTrbTNWDopVphUT34zUSVLK9pjsgYQ== dependencies: "@types/react" "*" @@ -5146,13 +4914,6 @@ dependencies: "@types/react" "*" -"@types/react-textarea-autosize@^4.3.3": - version "4.3.5" - resolved "https://registry.npmjs.org/@types/react-textarea-autosize/-/react-textarea-autosize-4.3.5.tgz#6c4d2753fa1864c98c0b2b517f67bb1f6e4c46de" - integrity sha512-PiDL83kPMTolyZAWW3lyzO6ktooTb9tFTntVy7CA83/qFLWKLJ5bLeRboy6J6j3b1e8h2Eec6gBTEOOJRjV14A== - dependencies: - "@types/react" "*" - "@types/react-transition-group@^4.2.0": version "4.2.4" resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.2.4.tgz#c7416225987ccdb719262766c1483da8f826838d" @@ -5182,6 +4943,13 @@ dependencies: csstype "^2.2.0" +"@types/reactcss@*": + version "1.2.3" + resolved "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.3.tgz#af28ae11bbb277978b99d04d1eedfd068ca71834" + integrity sha512-d2gQQ0IL6hXLnoRfVYZukQNWHuVsE75DzFTLPUuyyEhJS8G2VvlE+qfQQ91SJjaMqlURRCNIsX7Jcsw6cEuJlA== + dependencies: + "@types/react" "*" + "@types/recursive-readdir@^2.2.0": version "2.2.0" resolved "https://registry.npmjs.org/@types/recursive-readdir/-/recursive-readdir-2.2.0.tgz#b39cd5474fd58ea727fe434d5c68b7a20ba9121c" @@ -5201,6 +4969,13 @@ dependencies: "@types/node" "*" +"@types/responselike@*", "@types/responselike@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29" + integrity sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA== + dependencies: + "@types/node" "*" + "@types/rollup-plugin-peer-deps-external@^2.2.0": version "2.2.0" resolved "https://registry.npmjs.org/@types/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.0.tgz#eae7d8b9d27fa037f5bcaded24e389f85b81973c" @@ -5210,9 +4985,9 @@ rollup "^0.63.4" "@types/rollup-plugin-postcss@^2.0.0": - version "2.0.0" - resolved "https://registry.npmjs.org/@types/rollup-plugin-postcss/-/rollup-plugin-postcss-2.0.0.tgz#b1bcc686c7dbe441f2fcfb47cac01acc508f46c0" - integrity sha512-lw4LvCcWpQ/Yomb9bxT69uAN5ZfW6nhL9itEu3z0IzsNNqKalwIDo7pEK8jxoiHQmcPaYzayAfo4M1JT/h8crw== + version "2.0.1" + resolved "https://registry.npmjs.org/@types/rollup-plugin-postcss/-/rollup-plugin-postcss-2.0.1.tgz#f2c73a458b035db8af361c4a7d921f74c6d4dffa" + integrity sha512-q+o1V6536wwIIlwJzEjm09emg/1QIceJXo01h6fTA1JJY4p/QQTOc10mizp4NhmwRMB8LVOThCgkwvQgzvJFjQ== dependencies: "@types/cssnano" "*" "@types/node" "*" @@ -5233,12 +5008,12 @@ "@types/express-serve-static-core" "*" "@types/mime" "*" -"@types/sinonjs__fake-timers@6.0.1": +"@types/sinonjs__fake-timers@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz#681df970358c82836b42f989188d133e218c458e" integrity sha512-yYezQwGWty8ziyYLdZjwxyMb0CZR49h8JALHGrxjQHWlqGgc8kLdHEgWrgL0uZ29DMvEVBDnHU2Wg36zKSIUtA== -"@types/sizzle@*", "@types/sizzle@2.3.2": +"@types/sizzle@*", "@types/sizzle@^2.3.2": version "2.3.2" resolved "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47" integrity sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg== @@ -5316,21 +5091,14 @@ dependencies: "@types/estree" "*" -"@types/testing-library__cypress@^5.0.3": - version "5.0.3" - resolved "https://registry.npmjs.org/@types/testing-library__cypress/-/testing-library__cypress-5.0.3.tgz#94969b7c1eea96e09d8e023a1d225590fa75a1fe" - integrity sha512-efMwaVnsWEBDz0Ikm84Kh/oiUxMfz5LQtWx/Y6s2Bj0KkQ6+569/101X3Gz2bJy+otaXWPzOUOwDeAfzOJWoeQ== +"@types/testing-library__cypress@^5.0.6": + version "5.0.6" + resolved "https://registry.npmjs.org/@types/testing-library__cypress/-/testing-library__cypress-5.0.6.tgz#9015f575c1a98f05996a4fe769071134ee488c26" + integrity sha512-TUp5wfanU7zUZigKqIeQDChnHQ1MEzbYqrI5iCQMFiesWNOASWm/el1lFBh1JPqmd6GkdDdDiHYJnkqd9le2ww== dependencies: - "@types/testing-library__dom" "*" + "@testing-library/dom" "^7.11.0" cypress "*" -"@types/testing-library__dom@*": - version "6.14.0" - resolved "https://registry.npmjs.org/@types/testing-library__dom/-/testing-library__dom-6.14.0.tgz#1aede831cb4ed4a398448df5a2c54b54a365644e" - integrity sha512-sMl7OSv0AvMOqn1UJ6j1unPMIHRXen0Ita1ujnMX912rrOcawe4f7wu0Zt9GIQhBhJvH2BaibqFgQ3lP+Pj2hA== - dependencies: - pretty-format "^24.3.0" - "@types/testing-library__jest-dom@^5.9.1": version "5.9.1" resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.1.tgz#aba5ee062b7880f69c212ef769389f30752806e5" @@ -5375,15 +5143,15 @@ "@types/serve-static" "*" "@types/webpack" "*" -"@types/webpack-env@^1.15.0", "@types/webpack-env@^1.15.2": +"@types/webpack-env@^1.15.2": version "1.15.2" resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.15.2.tgz#927997342bb9f4a5185a86e6579a0a18afc33b0a" integrity sha512-67ZgZpAlhIICIdfQrB5fnDvaKFcDxpKibxznfYRVAT4mQE41Dido/3Ty+E3xGBmTogc5+0Qb8tWhna+5B8z1iQ== -"@types/webpack-node-externals@^1.7.1": - version "1.7.1" - resolved "https://registry.npmjs.org/@types/webpack-node-externals/-/webpack-node-externals-1.7.1.tgz#73d0d7ae0e98cfbd69b7443388302cd69217494a" - integrity sha512-kbO2gYPKvMb5j1KOgnIuUH52CKul9Ud4b10J5n+JX8oHmgu86hYpBVfrV4bMDe5lhCaO64h8QrKz7WnRZzqkbA== +"@types/webpack-node-externals@^2.5.0": + version "2.5.0" + resolved "https://registry.npmjs.org/@types/webpack-node-externals/-/webpack-node-externals-2.5.0.tgz#bcd161af84a4960416e5850e06931b35321c6654" + integrity sha512-KaWfhUQlpWknM/CMBKhV7i0vxX/N2xEy3WeaE500s4ZNxC4nLnKB+0F3gD3Fg+5octPq0nn8ZlfFR/P3dSkXpw== dependencies: "@types/webpack" "*" @@ -5397,9 +5165,9 @@ source-map "^0.6.1" "@types/webpack@*", "@types/webpack@^4.41.7", "@types/webpack@^4.41.8": - version "4.41.17" - resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.17.tgz#0a69005e644d657c85b7d6ec1c826a71bebd1c93" - integrity sha512-6FfeCidTSHozwKI67gIVQQ5Mp0g4X96c2IXxX75hYEQJwST/i6NyZexP//zzMOBb+wG9jJ7oO8fk9yObP2HWAw== + version "4.41.21" + resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.21.tgz#cc685b332c33f153bb2f5fc1fa3ac8adeb592dee" + integrity sha512-2j9WVnNrr/8PLAB5csW44xzQSJwS26aOnICsP3pSGCEdsu6KYtfQ6QJsVUKHWRnm1bL7HziJsfh5fHqth87yKA== dependencies: "@types/anymatch" "*" "@types/node" "*" @@ -5439,13 +5207,6 @@ resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" integrity sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw== -"@types/yargs@^13.0.0": - version "13.0.8" - resolved "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.8.tgz#a38c22def2f1c2068f8971acb3ea734eb3c64a99" - integrity sha512-XAvHLwG7UQ+8M4caKIH0ZozIOYay5fQkAgyIXegXT9jPtdIGdhga+sUEdAr1CiG46aB+c64xQEYyEzlwWVTNzA== - dependencies: - "@types/yargs-parser" "*" - "@types/yargs@^15.0.0": version "15.0.4" resolved "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.4.tgz#7e5d0f8ca25e9d5849f2ea443cf7c402decd8299" @@ -5651,6 +5412,18 @@ "@webassemblyjs/wast-parser" "1.9.0" "@xtuc/long" "4.2.2" +"@webpack-contrib/schema-utils@^1.0.0-beta.0": + version "1.0.0-beta.0" + resolved "https://registry.npmjs.org/@webpack-contrib/schema-utils/-/schema-utils-1.0.0-beta.0.tgz#bf9638c9464d177b48209e84209e23bee2eb4f65" + integrity sha512-LonryJP+FxQQHsjGBi6W786TQB1Oym+agTpY0c+Kj8alnIw+DLUJb6SI8Y1GHGhLCH1yPRrucjObUmxNICQ1pg== + dependencies: + ajv "^6.1.0" + ajv-keywords "^3.1.0" + chalk "^2.3.2" + strip-ansi "^4.0.0" + text-table "^0.2.0" + webpack-log "^1.1.2" + "@wry/equality@^0.1.2": version "0.1.11" resolved "https://registry.npmjs.org/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790" @@ -5766,12 +5539,7 @@ acorn@^6.0.1, acorn@^6.4.1: resolved "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474" integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA== -acorn@^7.1.1: - version "7.1.1" - resolved "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz#e35668de0b402f359de515c5482a1ab9f89a69bf" - integrity sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg== - -acorn@^7.2.0: +acorn@^7.1.1, acorn@^7.2.0: version "7.3.1" resolved "https://registry.npmjs.org/acorn/-/acorn-7.3.1.tgz#85010754db53c3fbaf3b9ea3e083aa5c5d147ffd" integrity sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA== @@ -5838,12 +5606,12 @@ ajv-errors@^1.0.0: resolved "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== -ajv-keywords@^3.1.0, ajv-keywords@^3.4.1: - version "3.4.1" - resolved "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da" - integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ== +ajv-keywords@^3.1.0, ajv-keywords@^3.4.1, ajv-keywords@^3.5.2: + version "3.5.2" + resolved "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" + integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@5: +ajv@5, ajv@^5.0.0: version "5.5.2" resolved "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" integrity sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU= @@ -5853,20 +5621,20 @@ ajv@5: fast-json-stable-stringify "^2.0.0" json-schema-traverse "^0.3.0" -ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.5.5, ajv@^6.7.0: - version "6.12.2" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd" - integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ== +ajv@6.5.2: + version "6.5.2" + resolved "https://registry.npmjs.org/ajv/-/ajv-6.5.2.tgz#678495f9b82f7cca6be248dd92f59bff5e1f4360" + integrity sha512-hOs7GfvI6tUI1LfZddH82ky6mOMyTuY0mk7kE2pWpmhhUSkumzaTO5vbVwij39MdwPQWCV4Zv57Eo06NtL/GVA== dependencies: - fast-deep-equal "^3.1.1" + fast-deep-equal "^2.0.1" fast-json-stable-stringify "^2.0.0" json-schema-traverse "^0.4.1" - uri-js "^4.2.2" + uri-js "^4.2.1" -ajv@^6.10.1: - version "6.12.3" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.3.tgz#18c5af38a111ddeb4f2697bd78d68abc1cabd706" - integrity sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA== +ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.12.4, ajv@^6.5.5, ajv@^6.7.0: + version "6.12.4" + resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" + integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -5907,13 +5675,6 @@ ansi-html@0.0.7: resolved "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= -ansi-red@^0.1.1: - version "0.1.1" - resolved "https://registry.npmjs.org/ansi-red/-/ansi-red-0.1.1.tgz#8c638f9d1080800a353c9c28c8a81ca4705d946c" - integrity sha1-jGOPnRCAgAo1PJwoyKgcpHBdlGw= - dependencies: - ansi-wrap "0.1.0" - ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" @@ -5924,7 +5685,7 @@ ansi-regex@^3.0.0: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= -ansi-regex@^4.0.0, ansi-regex@^4.1.0: +ansi-regex@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== @@ -5961,11 +5722,6 @@ ansi-to-html@^0.6.11: dependencies: entities "^1.1.2" -ansi-wrap@0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf" - integrity sha1-qCJQ3bABXponyoLoLqYDu/pF768= - any-observable@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" @@ -6172,9 +5928,9 @@ apollo-server@^2.16.1: graphql-tools "^4.0.0" apollo-tracing@^0.11.1: - version "0.11.1" - resolved "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.11.1.tgz#3e3a4ce4b21e57dcc57b10bbd539db243b752606" - integrity sha512-l7g+uILw7v32GA46IRXIx5XXbZhFI96BhSqrGK9yyvfq+NMcvVZrj3kIhRImPGhAjMdV+5biA/jztabElAbDjg== + version "0.11.2" + resolved "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.11.2.tgz#14308b176e021f5e6ec3ee670f8f96e9fbfdb50c" + integrity sha512-QjmRd2ozGD+PfmF6U9w/w6jrclYSBNczN6Bzppr8qA5somEGl5pqdprIZYL28H0IapZiutA3x6p6ZVF/cVX8wA== dependencies: apollo-server-env "^2.4.5" apollo-server-plugin-base "^0.9.1" @@ -6214,18 +5970,11 @@ aproba@^2.0.0: resolved "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== -arch@2.1.2, arch@^2.1.0: +arch@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz#0c52bbe7344bb4fa260c443d2cbad9c00ff2f0bf" integrity sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ== -archive-type@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/archive-type/-/archive-type-4.0.0.tgz#f92e72233056dfc6969472749c267bdb046b1d70" - integrity sha1-+S5yIzBW38aWlHJ0nCZ72wRrHXA= - dependencies: - file-type "^4.2.0" - are-we-there-yet@~1.1.2: version "1.1.5" resolved "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" @@ -6461,6 +6210,11 @@ async-retry@^1.2.1: dependencies: retry "0.12.0" +async@0.9.x: + version "0.9.2" + resolved "https://registry.npmjs.org/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" + integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0= + async@^2.6.1, async@^2.6.2: version "2.6.3" resolved "https://registry.npmjs.org/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" @@ -6473,11 +6227,6 @@ async@^3.2.0: resolved "https://registry.npmjs.org/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== -asyncapi@^2.6.1: - version "2.6.1" - resolved "https://registry.npmjs.org/asyncapi/-/asyncapi-2.6.1.tgz#5e35124abfd416f050a239bf72cb8eef7ebeda66" - integrity sha512-ROszLQMYzyp/CzPyLhuT/9NpJXxI7wrjv6yW8JpA/XVMEWCqyVXr9jajJhs9d/863+ispS+ptGhg1PC17AnIzQ== - asynckit@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -6510,27 +6259,7 @@ autolinker@^3.11.0: dependencies: tslib "^1.9.3" -autolinker@~0.28.0: - version "0.28.1" - resolved "https://registry.npmjs.org/autolinker/-/autolinker-0.28.1.tgz#0652b491881879f0775dace0cdca3233942a4e47" - integrity sha1-BlK0kYgYefB3XazgzcoyM5QqTkc= - dependencies: - gulp-header "^1.7.1" - autoprefixer@^9.7.2: - version "9.7.4" - resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.7.4.tgz#f8bf3e06707d047f0641d87aee8cfb174b2a5378" - integrity sha512-g0Ya30YrMBAEZk60lp+qfX5YQllG+S5W3GYCFvyHTvhOki0AEQJLPEcIuGRsqVwLi8FvXPVtwTGhfr38hVpm0g== - dependencies: - browserslist "^4.8.3" - caniuse-lite "^1.0.30001020" - chalk "^2.4.2" - normalize-range "^0.1.2" - num2fraction "^1.2.2" - postcss "^7.0.26" - postcss-value-parser "^4.0.2" - -autoprefixer@^9.7.5: version "9.8.6" resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.6.tgz#3b73594ca1bf9266320c5acf1588d74dea74210f" integrity sha512-XrvP4VVHdRBCdX1S3WXVD8+RyG9qeb1D5Sn1DeLiG2xfSpzellk5k54xbUERJ3M5DggQxes39UGOTP8CFrEGbg== @@ -6560,6 +6289,13 @@ axios@^0.19.0, axios@^0.19.2: dependencies: follow-redirects "1.5.10" +axios@^0.20.0: + version "0.20.0" + resolved "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz#057ba30f04884694993a8cd07fa394cff11c50bd" + integrity sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA== + dependencies: + follow-redirects "^1.10.0" + axobject-query@^2.0.2: version "2.1.2" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.1.2.tgz#2bdffc0371e643e5f03ba99065d5179b9ca79799" @@ -6609,20 +6345,31 @@ babel-helper-to-multiple-sequence-expressions@^0.5.0: resolved "https://registry.npmjs.org/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.5.0.tgz#a3f924e3561882d42fcf48907aa98f7979a4588d" integrity sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA== -babel-jest@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-26.0.1.tgz#450139ce4b6c17174b136425bda91885c397bc46" - integrity sha512-Z4GGmSNQ8pX3WS1O+6v3fo41YItJJZsVxG5gIQ+HuB/iuAQBJxMTHTwz292vuYws1LnHfwSRgoqI+nxdy/pcvw== +babel-jest@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-26.3.0.tgz#10d0ca4b529ca3e7d1417855ef7d7bd6fc0c3463" + integrity sha512-sxPnQGEyHAOPF8NcUsD0g7hDCnvLL2XyblRBcgrzTWBB/mAIpWow3n1bEL+VghnnZfreLhFSBsFluRoK2tRK4g== dependencies: - "@jest/transform" "^26.0.1" - "@jest/types" "^26.0.1" + "@jest/transform" "^26.3.0" + "@jest/types" "^26.3.0" "@types/babel__core" "^7.1.7" babel-plugin-istanbul "^6.0.0" - babel-preset-jest "^26.0.0" + babel-preset-jest "^26.3.0" chalk "^4.0.0" graceful-fs "^4.2.4" slash "^3.0.0" +babel-loader@^8.0.6: + version "8.1.0" + resolved "https://registry.npmjs.org/babel-loader/-/babel-loader-8.1.0.tgz#c611d5112bd5209abe8b9fa84c3e4da25275f1c3" + integrity sha512-7q7nC1tYOrqvUrN3LQK4GwSk/TQorZSOlO9C+RZDZpODgyN4ZlCqE5q9cDsyWOliN+aU9B4JX01xK9eJXowJLw== + dependencies: + find-cache-dir "^2.1.0" + loader-utils "^1.4.0" + mkdirp "^0.5.3" + pify "^4.0.1" + schema-utils "^2.6.5" + babel-plugin-add-react-displayname@^0.0.5: version "0.0.5" resolved "https://registry.npmjs.org/babel-plugin-add-react-displayname/-/babel-plugin-add-react-displayname-0.0.5.tgz#339d4cddb7b65fd62d1df9db9fe04de134122bd5" @@ -6662,16 +6409,17 @@ babel-plugin-istanbul@^6.0.0: istanbul-lib-instrument "^4.0.0" test-exclude "^6.0.0" -babel-plugin-jest-hoist@^26.0.0: - version "26.0.0" - resolved "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.0.0.tgz#fd1d35f95cf8849fc65cb01b5e58aedd710b34a8" - integrity sha512-+AuoehOrjt9irZL7DOt2+4ZaTM6dlu1s5TTS46JBa0/qem4dy7VNW3tMb96qeEqcIh20LD73TVNtmVEeymTG7w== +babel-plugin-jest-hoist@^26.2.0: + version "26.2.0" + resolved "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.2.0.tgz#bdd0011df0d3d513e5e95f76bd53b51147aca2dd" + integrity sha512-B/hVMRv8Nh1sQ1a3EY8I0n4Y1Wty3NrR5ebOyVT302op+DOAau+xNEImGMsUWOC3++ZlMooCytKz+NgN8aKGbA== dependencies: "@babel/template" "^7.3.3" "@babel/types" "^7.3.3" + "@types/babel__core" "^7.0.0" "@types/babel__traverse" "^7.0.6" -babel-plugin-macros@^2.0.0, babel-plugin-macros@^2.7.0: +babel-plugin-macros@^2.0.0, babel-plugin-macros@^2.8.0: version "2.8.0" resolved "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz#0f958a7cc6556b1e65344465d99111a1e5e10138" integrity sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg== @@ -6761,7 +6509,7 @@ babel-plugin-named-asset-import@^0.3.1: resolved "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.6.tgz#c9750a1b38d85112c9e166bf3ef7c5dbc605f4be" integrity sha512-1aGDUfL1qOOIoqk9QKGIo2lANk+C7ko/fqH0uIyC71x3PEGz0uVP8ISgfEsFuG+FKmjHTvFK/nNM8dowpmUxLA== -babel-plugin-react-docgen@^4.0.0: +babel-plugin-react-docgen@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/babel-plugin-react-docgen/-/babel-plugin-react-docgen-4.1.0.tgz#1dfa447dac9ca32d625a123df5733a9e47287c26" integrity sha512-vzpnBlfGv8XOhJM2zbPyyqw2OLEbelgZZsaaRRTpVwNKuYuc+pUg4+dy7i9gCRms0uOQn4osX571HRcCJMJCmA== @@ -6839,14 +6587,15 @@ babel-plugin-transform-undefined-to-void@^6.9.4: resolved "https://registry.npmjs.org/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280" integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA= -babel-preset-current-node-syntax@^0.1.2: - version "0.1.2" - resolved "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-0.1.2.tgz#fb4a4c51fe38ca60fede1dc74ab35eb843cb41d6" - integrity sha512-u/8cS+dEiK1SFILbOC8/rUI3ml9lboKuuMvZ/4aQnQmhecQAgPw5ew066C1ObnEAUmlx7dv/s2z52psWEtLNiw== +babel-preset-current-node-syntax@^0.1.3: + version "0.1.3" + resolved "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-0.1.3.tgz#b4b547acddbf963cba555ba9f9cbbb70bfd044da" + integrity sha512-uyexu1sVwcdFnyq9o8UQYsXwXflIh8LvrF5+cKrYam93ned1CStffB3+BEcsxGSgagoA3GEyjDqO4a/58hyPYQ== dependencies: "@babel/plugin-syntax-async-generators" "^7.8.4" "@babel/plugin-syntax-bigint" "^7.8.3" "@babel/plugin-syntax-class-properties" "^7.8.3" + "@babel/plugin-syntax-import-meta" "^7.8.3" "@babel/plugin-syntax-json-strings" "^7.8.3" "@babel/plugin-syntax-logical-assignment-operators" "^7.8.3" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" @@ -6888,13 +6637,13 @@ babel-preset-fbjs@^3.3.0: "@babel/plugin-transform-template-literals" "^7.0.0" babel-plugin-syntax-trailing-function-commas "^7.0.0-beta.0" -babel-preset-jest@^26.0.0: - version "26.0.0" - resolved "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.0.0.tgz#1eac82f513ad36c4db2e9263d7c485c825b1faa6" - integrity sha512-9ce+DatAa31DpR4Uir8g4Ahxs5K4W4L8refzt+qHWQANb6LhGcAEfIFgLUwk67oya2cCUd6t4eUMtO/z64ocNw== +babel-preset-jest@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.3.0.tgz#ed6344506225c065fd8a0b53e191986f74890776" + integrity sha512-5WPdf7nyYi2/eRxCbVrE1kKCWxgWY4RsPEbdJWFm7QsesFGqjdkyLeu1zRkwM1cxK6EPIlNd6d2AxLk7J+t4pw== dependencies: - babel-plugin-jest-hoist "^26.0.0" - babel-preset-current-node-syntax "^0.1.2" + babel-plugin-jest-hoist "^26.2.0" + babel-preset-current-node-syntax "^0.1.3" "babel-preset-minify@^0.5.0 || 0.6.0-alpha.5": version "0.5.1" @@ -6933,11 +6682,6 @@ babel-runtime@6.26.0, babel-runtime@^6.26.0: core-js "^2.4.0" regenerator-runtime "^0.11.0" -babylon@^6.18.0: - version "6.18.0" - resolved "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" - integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ== - backo2@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" @@ -7010,6 +6754,13 @@ before-after-hook@^2.0.0, before-after-hook@^2.1.0: resolved "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635" integrity sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A== +better-opn@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/better-opn/-/better-opn-2.0.0.tgz#c70d198e51164bdc220306a28a885d9ac7a14c44" + integrity sha512-PPbGRgO/K0LowMHbH/JNvaV3qY3Vt+A2nH28fzJxy16h/DfR5OsVti6ldGl6S9SMsyUqT13sltikiAVtI6tKLA== + dependencies: + open "^7.0.3" + bfj@^7.0.2: version "7.0.2" resolved "https://registry.npmjs.org/bfj/-/bfj-7.0.2.tgz#1988ce76f3add9ac2913fd8ba47aad9e651bfbb2" @@ -7025,54 +6776,6 @@ big.js@^5.2.2: resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== -bin-build@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/bin-build/-/bin-build-3.0.0.tgz#c5780a25a8a9f966d8244217e6c1f5082a143861" - integrity sha512-jcUOof71/TNAI2uM5uoUaDq2ePcVBQ3R/qhxAz1rX7UfvduAL/RXD3jXzvn8cVcDJdGVkiR1shal3OH0ImpuhA== - dependencies: - decompress "^4.0.0" - download "^6.2.2" - execa "^0.7.0" - p-map-series "^1.0.0" - tempfile "^2.0.0" - -bin-check@^4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/bin-check/-/bin-check-4.1.0.tgz#fc495970bdc88bb1d5a35fc17e65c4a149fc4a49" - integrity sha512-b6weQyEUKsDGFlACWSIOfveEnImkJyK/FGW6FAG42loyoquvjdtOIqO6yBFzHyqyVVhNgNkQxxx09SFLK28YnA== - dependencies: - execa "^0.7.0" - executable "^4.1.0" - -bin-version-check@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/bin-version-check/-/bin-version-check-4.0.0.tgz#7d819c62496991f80d893e6e02a3032361608f71" - integrity sha512-sR631OrhC+1f8Cvs8WyVWOA33Y8tgwjETNPyyD/myRBXLkfS/vl74FmH/lFcRl9KY3zwGh7jFhvyk9vV3/3ilQ== - dependencies: - bin-version "^3.0.0" - semver "^5.6.0" - semver-truncate "^1.1.2" - -bin-version@^3.0.0: - version "3.1.0" - resolved "https://registry.npmjs.org/bin-version/-/bin-version-3.1.0.tgz#5b09eb280752b1bd28f0c9db3f96f2f43b6c0839" - integrity sha512-Mkfm4iE1VFt4xd4vH+gx+0/71esbfus2LsnCGe8Pi4mndSPyT+NGES/Eg99jx8/lUGWfu3z2yuB/bt5UB+iVbQ== - dependencies: - execa "^1.0.0" - find-versions "^3.0.0" - -bin-wrapper@^4.0.0: - version "4.1.0" - resolved "https://registry.npmjs.org/bin-wrapper/-/bin-wrapper-4.1.0.tgz#99348f2cf85031e3ef7efce7e5300aeaae960605" - integrity sha512-hfRmo7hWIXPkbpi0ZltboCMVrU+0ClXR/JgbCKKjlDjQf6igXa7OwdqNcFWQZPZTgiY7ZpzE3+LjjkLiTN2T7Q== - dependencies: - bin-check "^4.1.0" - bin-version-check "^4.0.0" - download "^7.1.0" - import-lazy "^3.1.0" - os-filter-obj "^2.0.0" - pify "^4.0.1" - binary-extensions@^1.0.0: version "1.13.1" resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" @@ -7090,6 +6793,11 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" +bintrees@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524" + integrity sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ= + bl@^1.0.0: version "1.2.2" resolved "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c" @@ -7107,7 +6815,7 @@ bl@^4.0.1: inherits "^2.0.4" readable-stream "^3.4.0" -bluebird@3.7.2, bluebird@^3.3.5, bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5: +bluebird@3.7.2, bluebird@^3.3.5, bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5, bluebird@^3.7.2: version "3.7.2" resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== @@ -7117,7 +6825,7 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: resolved "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== -body-parser@1.19.0, body-parser@^1.18.3, body-parser@^1.19.0: +body-parser@1.19.0, body-parser@^1.18.3: version "1.19.0" resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== @@ -7133,16 +6841,6 @@ body-parser@1.19.0, body-parser@^1.18.3, body-parser@^1.19.0: raw-body "2.4.0" type-is "~1.6.17" -body@^5.1.0: - version "5.1.0" - resolved "https://registry.npmjs.org/body/-/body-5.1.0.tgz#e4ba0ce410a46936323367609ecb4e6553125069" - integrity sha1-5LoM5BCkaTYyM2dgnstOZVMSUGk= - dependencies: - continuable-cache "^0.3.1" - error "^7.0.0" - raw-body "~1.1.0" - safe-json-parse "~1.0.1" - bonjour@^3.5.0: version "3.5.0" resolved "https://registry.npmjs.org/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" @@ -7279,7 +6977,7 @@ browserify-zlib@^0.2.0: dependencies: pako "~1.0.5" -browserslist@4.10.0, browserslist@^4.0.0, browserslist@^4.8.3: +browserslist@4.10.0: version "4.10.0" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.10.0.tgz#f179737913eaf0d2b98e4926ac1ca6a15cbcc6a9" integrity sha512-TpfK0TDgv71dzuTsEAlQiHeWQ/tiPqgNZVdv046fvNtBZrjbv2O3TsWCDU0AWGJJKCF/KsjNdLzR9hXOsh/CfA== @@ -7289,16 +6987,7 @@ browserslist@4.10.0, browserslist@^4.0.0, browserslist@^4.8.3: node-releases "^1.1.52" pkg-up "^3.1.0" -browserslist@4.7.0: - version "4.7.0" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.7.0.tgz#9ee89225ffc07db03409f2fee524dc8227458a17" - integrity sha512-9rGNDtnj+HaahxiVV38Gn8n8Lr8REKsel68v1sPFfIGEK6uSXTY3h9acgiT1dZVtOOUtifo/Dn8daDQ5dUgVsA== - dependencies: - caniuse-lite "^1.0.30000989" - electron-to-chromium "^1.3.247" - node-releases "^1.1.29" - -browserslist@^4.12.0: +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.3: version "4.13.0" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.13.0.tgz#42556cba011e1b0a2775b611cba6a8eca18e940d" integrity sha512-MINatJ5ZNrLnQ6blGvePd/QOz9Xtu+Ne+x29iQSCHfkU5BugKVJwZKn/iiL8UbpIpa3JhviKjz+XxMo0m2caFQ== @@ -7394,7 +7083,7 @@ buffer@^4.3.0: ieee754 "^1.1.4" isarray "^1.0.0" -buffer@^5.2.1, buffer@^5.5.0, buffer@^5.6.0: +buffer@^5.5.0, buffer@^5.6.0: version "5.6.0" resolved "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== @@ -7434,11 +7123,6 @@ byte-size@^5.0.1: resolved "https://registry.npmjs.org/byte-size/-/byte-size-5.0.1.tgz#4b651039a5ecd96767e71a3d7ed380e48bed4191" integrity sha512-/XuKeqWocKsYa/cBY1YbSJSWWqTi4cFgr9S6OyM7PBaPbr9zvNGwWP33vt0uqGhwDdN+y3yhbXVILEUpnwEWGw== -bytes@1: - version "1.0.0" - resolved "https://registry.npmjs.org/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8" - integrity sha1-NWnt6Lo0MV+rmcPpLLBMciDeH6g= - bytes@3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -7470,28 +7154,27 @@ cacache@^12.0.0, cacache@^12.0.2, cacache@^12.0.3: unique-filename "^1.1.1" y18n "^4.0.0" -cacache@^13.0.1: - version "13.0.1" - resolved "https://registry.npmjs.org/cacache/-/cacache-13.0.1.tgz#a8000c21697089082f85287a1aec6e382024a71c" - integrity sha512-5ZvAxd05HDDU+y9BVvcqYu2LLXmPnQ0hW62h32g4xBTgL/MppR4/04NHfj/ycM2y6lmTnbw6HVi+1eN0Psba6w== +cacache@^15.0.5: + version "15.0.5" + resolved "https://registry.npmjs.org/cacache/-/cacache-15.0.5.tgz#69162833da29170d6732334643c60e005f5f17d0" + integrity sha512-lloiL22n7sOjEEXdL8NAjTgv9a1u43xICE9/203qonkZUCj5X1UEWIdf2/Y0d6QcCtMzbKQyhrcDbdvlZTs/+A== dependencies: - chownr "^1.1.2" - figgy-pudding "^3.5.1" + "@npmcli/move-file" "^1.0.1" + chownr "^2.0.0" fs-minipass "^2.0.0" glob "^7.1.4" - graceful-fs "^4.2.2" infer-owner "^1.0.4" - lru-cache "^5.1.1" - minipass "^3.0.0" + lru-cache "^6.0.0" + minipass "^3.1.1" minipass-collect "^1.0.2" minipass-flush "^1.0.5" minipass-pipeline "^1.2.2" - mkdirp "^0.5.1" - move-concurrently "^1.0.1" - p-map "^3.0.0" + mkdirp "^1.0.3" + p-map "^4.0.0" promise-inflight "^1.0.1" - rimraf "^2.7.1" - ssri "^7.0.0" + rimraf "^3.0.2" + ssri "^8.0.0" + tar "^6.0.2" unique-filename "^1.1.1" cache-base@^1.0.1: @@ -7509,18 +7192,18 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" -cacheable-request@^2.1.1: - version "2.1.4" - resolved "https://registry.npmjs.org/cacheable-request/-/cacheable-request-2.1.4.tgz#0d808801b6342ad33c91df9d0b44dc09b91e5c3d" - integrity sha1-DYCIAbY0KtM8kd+dC0TcCbkeXD0= +cacheable-lookup@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-2.0.1.tgz#87be64a18b925234875e10a9bb1ebca4adce6b38" + integrity sha512-EMMbsiOTcdngM/K6gV/OxF2x0t07+vMOWxZNSCRQMjO2MY2nhZQ6OYhOOpyQrbhqsgtvKGI7hcq6xjnA92USjg== dependencies: - clone-response "1.0.2" - get-stream "3.0.0" - http-cache-semantics "3.8.1" - keyv "3.0.0" - lowercase-keys "1.0.0" - normalize-url "2.0.1" - responselike "1.0.2" + "@types/keyv" "^3.1.1" + keyv "^4.0.0" + +cacheable-lookup@^5.0.3: + version "5.0.3" + resolved "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.3.tgz#049fdc59dffdd4fc285e8f4f82936591bd59fec3" + integrity sha512-W+JBqF9SWe18A72XFzN/V/CULFzPm7sBXzzR6ekkE+3tLG72wFZrBiBZhrZuDoYexop4PHJVdFAKb/Nj9+tm9w== cacheable-request@^6.0.0: version "6.1.0" @@ -7535,7 +7218,20 @@ cacheable-request@^6.0.0: normalize-url "^4.1.0" responselike "^1.0.2" -cachedir@2.3.0: +cacheable-request@^7.0.1: + version "7.0.1" + resolved "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.1.tgz#062031c2856232782ed694a257fa35da93942a58" + integrity sha512-lt0mJ6YAnsrBErpTMWeu5kl/tg9xMAWjavYTN6VQXM1A/teBITuNcccXsCxF0tDQQJf9DfAaX5O4e0zp0KlfZw== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^4.0.0" + lowercase-keys "^2.0.0" + normalize-url "^4.1.0" + responselike "^2.0.0" + +cachedir@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== @@ -7628,11 +7324,6 @@ camelcase@^6.0.0: resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e" integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w== -can-use-dom@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/can-use-dom/-/can-use-dom-0.1.0.tgz#22cc4a34a0abc43950f42c6411024a3f6366b45a" - integrity sha1-IsxKNKCrxDlQ9CxkEQJKP2NmtFo= - caniuse-api@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" @@ -7643,21 +7334,20 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001020, caniuse-lite@^1.0.30001035: - version "1.0.30001035" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001035.tgz#2bb53b8aa4716b2ed08e088d4dc816a5fe089a1e" - integrity sha512-C1ZxgkuA4/bUEdMbU5WrGY4+UhMFFiXrgNAfxiMIqWgFTWfv/xsZCS2xEHT2LMq7xAZfuAnu6mcqyDl0ZR6wLQ== - -caniuse-lite@^1.0.30001093: - version "1.0.30001107" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001107.tgz#809360df7a5b3458f627aa46b0f6ed6d5239da9a" - integrity sha512-86rCH+G8onCmdN4VZzJet5uPELII59cUzDphko3thQFgAQG1RNa+sVLDoALIhRYmflo5iSIzWY3vu1XTWtNMQQ== - -caniuse-lite@^1.0.30001109: +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001035, caniuse-lite@^1.0.30001093, caniuse-lite@^1.0.30001109: version "1.0.30001113" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001113.tgz#22016ab55b5a8b04fa00ca342d9ee1b98df48065" integrity sha512-qMvjHiKH21zzM/VDZr6oosO6Ri3U0V2tC015jRXjOecwQCJtsU5zklTNTk31jQbIOP8gha0h1ccM/g0ECP+4BA== +canvas@^2.6.1: + version "2.6.1" + resolved "https://registry.npmjs.org/canvas/-/canvas-2.6.1.tgz#0d087dd4d60f5a5a9efa202757270abea8bef89e" + integrity sha512-S98rKsPcuhfTcYbtF53UIJhcbgIAK533d1kJKMwsMwAIFgfd58MOyxRud3kktlzWiEkFliaJtvyZCBtud/XVEA== + dependencies: + nan "^2.14.0" + node-pre-gyp "^0.11.0" + simple-get "^3.0.3" + canvg@1.5.3: version "1.5.3" resolved "https://registry.npmjs.org/canvg/-/canvg-1.5.3.tgz#aad17915f33368bf8eb80b25d129e3ae922ddc5f" @@ -7685,17 +7375,7 @@ caseless@~0.12.0: resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= -caw@^2.0.0, caw@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/caw/-/caw-2.0.1.tgz#6c3ca071fc194720883c2dc5da9b074bfc7e9e95" - integrity sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA== - dependencies: - get-proxy "^2.0.0" - isurl "^1.0.0-alpha5" - tunnel-agent "^0.6.0" - url-to-options "^1.0.1" - -chalk@2.4.2, chalk@^2.0.0, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.4.1, chalk@^2.4.2: +chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.3.2, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -7756,7 +7436,7 @@ chardet@^0.7.0: resolved "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== -check-more-types@2.24.0: +check-more-types@2.24.0, check-more-types@^2.24.0: version "2.24.0" resolved "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" integrity sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA= @@ -7766,29 +7446,7 @@ check-types@^11.1.1: resolved "https://registry.npmjs.org/check-types/-/check-types-11.1.2.tgz#86a7c12bf5539f6324eb0e70ca8896c0e38f3e2f" integrity sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ== -cheerio@0.22.0: - version "0.22.0" - resolved "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz#a9baa860a3f9b595a6b81b1a86873121ed3a269e" - integrity sha1-qbqoYKP5tZWmuBsahocxIe06Jp4= - dependencies: - css-select "~1.2.0" - dom-serializer "~0.1.0" - entities "~1.1.1" - htmlparser2 "^3.9.1" - lodash.assignin "^4.0.9" - lodash.bind "^4.1.4" - lodash.defaults "^4.0.1" - lodash.filter "^4.4.0" - lodash.flatten "^4.2.0" - lodash.foreach "^4.3.0" - lodash.map "^4.4.0" - lodash.merge "^4.4.0" - lodash.pick "^4.2.1" - lodash.reduce "^4.4.0" - lodash.reject "^4.4.0" - lodash.some "^4.4.0" - -chokidar@3.4.1, chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1: +chokidar@3.4.1: version "3.4.1" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.4.1.tgz#e905bdecf10eaa0a0b1db0c664481cc4cbc22ba1" integrity sha512-TQTJyr2stihpC4Sya9hs2Xh+O2wf+igjL36Y75xx2WdHuiICcn/XJza46Jwt0eT5hVpQOzo3FpY3cj3RVYLX0g== @@ -7803,7 +7461,7 @@ chokidar@3.4.1, chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1: optionalDependencies: fsevents "~2.1.2" -chokidar@^2.0.4, chokidar@^2.1.8: +chokidar@^2.1.8: version "2.1.8" resolved "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== @@ -7822,6 +7480,21 @@ chokidar@^2.0.4, chokidar@^2.1.8: optionalDependencies: fsevents "^1.2.7" +chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1, chokidar@^3.4.1: + version "3.4.2" + resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d" + integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.4.0" + optionalDependencies: + fsevents "~2.1.2" + chownr@^1.0.1, chownr@^1.1.1, chownr@^1.1.2: version "1.1.4" resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" @@ -7917,7 +7590,17 @@ cli-spinners@^2.2.0: resolved "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.2.0.tgz#e8b988d9206c692302d8ee834e7a85c0144d8f77" integrity sha512-tgU3fKwzYjiLEQgPMD9Jt+JjHVL9kW93FiIMX/l7rivvOD4/LL0Mf7gda3+4U2KJBloybwgj5KEoQgGRioMiKQ== -cli-table3@0.5.1: +cli-table3@0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.0.tgz#b7b1bc65ca8e7b5cef9124e13dc2b21e2ce4faee" + integrity sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ== + dependencies: + object-assign "^4.1.0" + string-width "^4.2.0" + optionalDependencies: + colors "^1.1.2" + +cli-table3@~0.5.1: version "0.5.1" resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202" integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw== @@ -8009,7 +7692,7 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" -clone-response@1.0.2, clone-response@^1.0.2: +clone-response@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws= @@ -8040,6 +7723,11 @@ coa@^2.0.2: chalk "^2.4.1" q "^1.1.2" +code-error-fragment@0.0.230: + version "0.0.230" + resolved "https://registry.npmjs.org/code-error-fragment/-/code-error-fragment-0.0.230.tgz#d736d75c832445342eca1d1fedbf17d9618b14d7" + integrity sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw== + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -8058,11 +7746,6 @@ codemirror@^5.52.2: resolved "https://registry.npmjs.org/codemirror/-/codemirror-5.53.2.tgz#9799121cf8c50809cca487304e9de3a74d33f428" integrity sha512-wvSQKS4E+P8Fxn/AQ+tQtJnF1qH5UOlxtugFLpubEZ5jcdH2iXTVinb+Xc/4QjshuOxRm4fUsU2QPF1JJKiyXA== -coffee-script@^1.12.4: - version "1.12.7" - resolved "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz#c05dae0cb79591d05b3070a8433a98c9a89ccc53" - integrity sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw== - collapse-white-space@^1.0.2: version "1.0.6" resolved "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz#e63629c0016665792060dbbeb79c42239d2c5287" @@ -8129,12 +7812,7 @@ color@^3.0.0, color@^3.1.2: color-convert "^1.9.1" color-string "^1.5.2" -colorette@1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/colorette/-/colorette-1.1.0.tgz#1f943e5a357fac10b4e0f5aaef3b14cdc1af6ec7" - integrity sha512-6S062WDQUXi6hOfkO/sBPVwE5ASXY4G2+b4atvhJfSsuUUhIaUKlkjLe9692Ipyt5/a+IPF5aVTu3V5gvXq5cg== - -colorette@^1.2.1: +colorette@1.2.1, colorette@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw== @@ -8177,22 +7855,32 @@ comma-separated-tokens@^1.0.0: resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== -commander@4.1.1, commander@^4.0.0, commander@^4.0.1, commander@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" - integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== +command-exists-promise@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/command-exists-promise/-/command-exists-promise-2.0.2.tgz#7beecc4b218299f3c61fa69a4047aa0b36a64a99" + integrity sha512-T6PB6vdFrwnHXg/I0kivM3DqaCGZLjjYSOe0a5WgFKcz1sOnmOeIjnhQPXVXX3QjVbLyTJ85lJkX6lUpukTzaA== -commander@^2.11.0, commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.8.1, commander@~2.20.3: +commander@^2.11.0, commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@~2.20.3: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commander@^4.0.0, commander@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + commander@^5.0.0, commander@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== -common-tags@1.8.0: +commander@^6.1.0: + version "6.1.0" + resolved "https://registry.npmjs.org/commander/-/commander-6.1.0.tgz#f8d722b78103141006b66f4c7ba1e97315ba75bc" + integrity sha512-wl7PNrYWd2y5mp1OK/LhTlv8Ff4kQJQRXXAvF+uU/TPNiVJUxZLRYGj/B0y/lPGAVcSbJqH2Za/cvHmrPMC8mA== + +common-tags@1.8.0, common-tags@^1.8.0: version "1.8.0" resolved "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw== @@ -8264,7 +7952,7 @@ concat-map@0.0.1: resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@^1.5.0, concat-stream@^1.5.2, concat-stream@^1.6.2: +concat-stream@^1.5.0, concat-stream@^1.6.2: version "1.6.2" resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== @@ -8274,7 +7962,7 @@ concat-stream@^1.5.0, concat-stream@^1.5.2, concat-stream@^1.6.2: readable-stream "^2.2.2" typedarray "^0.0.6" -concat-stream@^2.0.0, concat-stream@~2.0.0: +concat-stream@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== @@ -8284,7 +7972,7 @@ concat-stream@^2.0.0, concat-stream@~2.0.0: readable-stream "^3.0.2" typedarray "^0.0.6" -concat-with-sourcemaps@*, concat-with-sourcemaps@^1.1.0: +concat-with-sourcemaps@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz#d4ea93f05ae25790951b99e7b3b09e3908a4082e" integrity sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg== @@ -8341,11 +8029,6 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0: resolved "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= -console-stream@^0.1.1: - version "0.1.1" - resolved "https://registry.npmjs.org/console-stream/-/console-stream-0.1.1.tgz#a095fe07b20465955f2fafd28b5d72bccd949d44" - integrity sha1-oJX+B7IEZZVfL6/Si11yvM2UnUQ= - constant-case@3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/constant-case/-/constant-case-3.0.3.tgz#ac910a99caf3926ac5112f352e3af599d8c5fc0a" @@ -8375,7 +8058,7 @@ content-disposition@0.5.2: resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= -content-disposition@0.5.3, content-disposition@^0.5.2: +content-disposition@0.5.3: version "0.5.3" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g== @@ -8387,11 +8070,6 @@ content-type@~1.0.4: resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== -continuable-cache@^0.3.1: - version "0.3.1" - resolved "https://registry.npmjs.org/continuable-cache/-/continuable-cache-0.3.1.tgz#bd727a7faed77e71ff3985ac93351a912733ad0f" - integrity sha1-vXJ6f67XfnH/OYWskzUakSczrQ8= - conventional-changelog-angular@^5.0.3: version "5.0.6" resolved "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.6.tgz#269540c624553aded809c29a3508fdc2b544c059" @@ -8552,7 +8230,7 @@ core-js@^2.4.0, core-js@^2.4.1, core-js@^2.5.7, core-js@^2.6.11, core-js@^2.6.5: resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== -core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0: +core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.5: version "3.6.5" resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== @@ -8562,14 +8240,6 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= -corejs-upgrade-webpack-plugin@^2.2.0: - version "2.2.0" - resolved "https://registry.npmjs.org/corejs-upgrade-webpack-plugin/-/corejs-upgrade-webpack-plugin-2.2.0.tgz#503293bf1fdcb104918eb40d0294e4776ad6923a" - integrity sha512-J0QMp9GNoiw91Kj/dkIQFZeiCXgXoja/Wlht1SPybxerBWh4NCmb0pOgCv61lrlQZETwvVVfAFAA3IqoEO9aqQ== - dependencies: - resolve-from "^5.0.0" - webpack "^4.38.0" - cors@^2.8.4, cors@^2.8.5: version "2.8.5" resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" @@ -8589,7 +8259,7 @@ cosmiconfig@6.0.0, cosmiconfig@^6.0.0: path-type "^4.0.0" yaml "^1.7.2" -cosmiconfig@7.0.0: +cosmiconfig@7.0.0, cosmiconfig@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz#ef9b44d773959cae63ddecd122de23853b60f8d3" integrity sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA== @@ -8600,7 +8270,7 @@ cosmiconfig@7.0.0: path-type "^4.0.0" yaml "^1.10.0" -cosmiconfig@^5.0.0, cosmiconfig@^5.1.0, cosmiconfig@^5.2.1: +cosmiconfig@^5.0.0, cosmiconfig@^5.1.0: version "5.2.1" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA== @@ -8664,32 +8334,13 @@ cross-fetch@2.2.2: node-fetch "2.1.2" whatwg-fetch "2.0.4" -cross-fetch@3.0.5, cross-fetch@^3.0.5: +cross-fetch@3.0.5, cross-fetch@^3.0.4, cross-fetch@^3.0.5: version "3.0.5" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.5.tgz#2739d2981892e7ab488a7ad03b92df2816e03f4c" integrity sha512-FFLcLtraisj5eteosnX1gf01qYDCOc4fDy0+euOt8Kn9YBY2NtXL/pCoYPavw24NIQkQqm5ZOLsGD5Zzj0gyew== dependencies: node-fetch "2.6.0" -cross-fetch@^3.0.4: - version "3.0.4" - resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.4.tgz#7bef7020207e684a7638ef5f2f698e24d9eb283c" - integrity sha512-MSHgpjQqgbT/94D4CyADeNoYh52zMkCX4pcJvPP5WqPsLFMKjr2TCMg381ox5qI0ii2dPwaLx/00477knXqXVw== - dependencies: - node-fetch "2.6.0" - whatwg-fetch "3.0.0" - -cross-spawn@6.0.5, cross-spawn@^6.0.0: - version "6.0.5" - resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" - integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== - dependencies: - nice-try "^1.0.4" - path-key "^2.0.1" - semver "^5.5.0" - shebang-command "^1.2.0" - which "^1.2.9" - cross-spawn@7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.1.tgz#0ab56286e0f7c24e153d04cc2aa027e43a9a5d14" @@ -8699,12 +8350,14 @@ cross-spawn@7.0.1: shebang-command "^2.0.0" which "^2.0.1" -cross-spawn@^5.0.1: - version "5.1.0" - resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" - integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk= +cross-spawn@^6.0.0: + version "6.0.5" + resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== dependencies: - lru-cache "^4.0.1" + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" shebang-command "^1.2.0" which "^1.2.9" @@ -8717,15 +8370,6 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -crowdin-cli@^0.3.0: - version "0.3.0" - resolved "https://registry.npmjs.org/crowdin-cli/-/crowdin-cli-0.3.0.tgz#eac9989a6fe7feaaf33090397afc187c67b46191" - integrity sha1-6smYmm/n/qrzMJA5evwYfGe0YZE= - dependencies: - request "^2.53.0" - yamljs "^0.2.1" - yargs "^2.3.0" - crypto-browserify@^3.11.0: version "3.12.0" resolved "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" @@ -8783,7 +8427,7 @@ css-line-break@1.0.1: dependencies: base64-arraybuffer "^0.1.5" -css-loader@^3.0.0, css-loader@^3.5.3: +css-loader@^3.5.3: version "3.6.0" resolved "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz#2e4b2c7e6e2d27f8c8f28f61bffcd2e6c91ef645" integrity sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ== @@ -8819,7 +8463,7 @@ css-select-base-adapter@^0.1.1: resolved "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" integrity sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w== -css-select@^1.1.0, css-select@~1.2.0: +css-select@^1.1.0: version "1.2.0" resolved "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg= @@ -9031,71 +8675,71 @@ cyclist@^1.0.1: integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= cypress@*, cypress@^4.2.0: - version "4.9.0" - resolved "https://registry.npmjs.org/cypress/-/cypress-4.9.0.tgz#c188a3864ddf841c0fdc81a9e4eff5cf539cd1c1" - integrity sha512-qGxT5E0j21FPryzhb0OBjCdhoR/n1jXtumpFFSBPYWsaZZhNaBvc3XlBUDEZKkkXPsqUFYiyhWdHN/zo0t5FcA== + version "4.12.1" + resolved "https://registry.npmjs.org/cypress/-/cypress-4.12.1.tgz#0ead1b9f4c0917d69d8b57f996b6e01fe693b6ec" + integrity sha512-9SGIPEmqU8vuRA6xst2CMTYd9sCFCxKSzrHt0wr+w2iAQMCIIsXsQ5Gplns1sT6LDbZcmLv6uehabAOl3fhc9Q== dependencies: - "@cypress/listr-verbose-renderer" "0.4.1" - "@cypress/request" "2.88.5" - "@cypress/xvfb" "1.2.4" - "@types/sinonjs__fake-timers" "6.0.1" - "@types/sizzle" "2.3.2" - arch "2.1.2" - bluebird "3.7.2" - cachedir "2.3.0" - chalk "2.4.2" - check-more-types "2.24.0" - cli-table3 "0.5.1" - commander "4.1.1" - common-tags "1.8.0" - debug "4.1.1" - eventemitter2 "6.4.2" - execa "1.0.0" - executable "4.1.1" - extract-zip "1.7.0" - fs-extra "8.1.0" - getos "3.2.1" - is-ci "2.0.0" - is-installed-globally "0.3.2" - lazy-ass "1.6.0" - listr "0.14.3" - lodash "4.17.15" - log-symbols "3.0.0" - minimist "1.2.5" - moment "2.26.0" - ospath "1.2.2" - pretty-bytes "5.3.0" - ramda "0.26.1" - request-progress "3.0.0" - supports-color "7.1.0" - tmp "0.1.0" - untildify "4.0.0" - url "0.11.0" - yauzl "2.10.0" + "@cypress/listr-verbose-renderer" "^0.4.1" + "@cypress/request" "^2.88.5" + "@cypress/xvfb" "^1.2.4" + "@types/sinonjs__fake-timers" "^6.0.1" + "@types/sizzle" "^2.3.2" + arch "^2.1.2" + bluebird "^3.7.2" + cachedir "^2.3.0" + chalk "^2.4.2" + check-more-types "^2.24.0" + cli-table3 "~0.5.1" + commander "^4.1.1" + common-tags "^1.8.0" + debug "^4.1.1" + eventemitter2 "^6.4.2" + execa "^1.0.0" + executable "^4.1.1" + extract-zip "^1.7.0" + fs-extra "^8.1.0" + getos "^3.2.1" + is-ci "^2.0.0" + is-installed-globally "^0.3.2" + lazy-ass "^1.6.0" + listr "^0.14.3" + lodash "^4.17.19" + log-symbols "^3.0.0" + minimist "^1.2.5" + moment "^2.27.0" + ospath "^1.2.2" + pretty-bytes "^5.3.0" + ramda "~0.26.1" + request-progress "^3.0.0" + supports-color "^7.1.0" + tmp "~0.1.0" + untildify "^4.0.0" + url "^0.11.0" + yauzl "^2.10.0" -d3-dispatch@1: - version "1.0.6" - resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58" - integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA== +"d3-dispatch@1 - 2": + version "2.0.0" + resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz#8a18e16f76dd3fcaef42163c97b926aa9b55e7cf" + integrity sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA== d3-force@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/d3-force/-/d3-force-2.0.1.tgz#31750eee8c43535301d571195bf9683beda534e2" - integrity sha512-zh73/N6+MElRojiUG7vmn+3vltaKon7iD5vB/7r9nUaBeftXMzRo5IWEG63DLBCto4/8vr9i3m9lwr1OTJNiCg== + version "2.1.1" + resolved "https://registry.npmjs.org/d3-force/-/d3-force-2.1.1.tgz#f20ccbf1e6c9e80add1926f09b51f686a8bc0937" + integrity sha512-nAuHEzBqMvpFVMf9OX75d00OxvOXdxY+xECIXjW6Gv8BRrXu6gAWbv/9XKrvfJ5i5DCokDW7RYE50LRoK092ew== dependencies: - d3-dispatch "1" - d3-quadtree "1" - d3-timer "1" + d3-dispatch "1 - 2" + d3-quadtree "1 - 2" + d3-timer "1 - 2" -d3-quadtree@1: - version "1.0.7" - resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz#ca8b84df7bb53763fe3c2f24bd435137f4e53135" - integrity sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA== +"d3-quadtree@1 - 2": + version "2.0.0" + resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-2.0.0.tgz#edbad045cef88701f6fee3aee8e93fb332d30f9d" + integrity sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw== -d3-timer@1: - version "1.0.10" - resolved "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5" - integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw== +"d3-timer@1 - 2": + version "2.0.0" + resolved "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz#055edb1d170cfe31ab2da8968deee940b56623e6" + integrity sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA== d@1, d@^1.0.1: version "1.0.1" @@ -9186,13 +8830,6 @@ debug@3.1.0, debug@=3.1.0: dependencies: ms "2.0.0" -debug@4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/debug/-/debug-4.1.0.tgz#373687bffa678b38b1cd91f861b63850035ddc87" - integrity sha512-heNPJUJIqC+xB6ayLAMHaIrmN9HKa7aQO8MGqKpvCA+uJYVcvR6l5kgdrhRuwPFHU7P5/A1w0BjByPHwpfTDKg== - dependencies: - ms "^2.1.1" - debug@4.1.1, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" @@ -9235,65 +8872,33 @@ decode-uri-component@^0.2.0: resolved "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= -decompress-response@^3.2.0, decompress-response@^3.3.0: +decompress-response@^3.3.0: version "3.3.0" resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M= dependencies: mimic-response "^1.0.0" -decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz#718cbd3fcb16209716e70a26b84e7ba4592e5af1" - integrity sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ== - dependencies: - file-type "^5.2.0" - is-stream "^1.1.0" - tar-stream "^1.5.2" - -decompress-tarbz2@^4.0.0: - version "4.1.1" - resolved "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz#3082a5b880ea4043816349f378b56c516be1a39b" - integrity sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A== - dependencies: - decompress-tar "^4.1.0" - file-type "^6.1.0" - is-stream "^1.1.0" - seek-bzip "^1.0.5" - unbzip2-stream "^1.0.9" - -decompress-targz@^4.0.0: - version "4.1.1" - resolved "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz#c09bc35c4d11f3de09f2d2da53e9de23e7ce1eee" - integrity sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w== - dependencies: - decompress-tar "^4.1.1" - file-type "^5.2.0" - is-stream "^1.1.0" - -decompress-unzip@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz#deaaccdfd14aeaf85578f733ae8210f9b4848f69" - integrity sha1-3qrM39FK6vhVePczroIQ+bSEj2k= - dependencies: - file-type "^3.8.0" - get-stream "^2.2.0" - pify "^2.3.0" - yauzl "^2.4.2" - -decompress@^4.0.0, decompress@^4.2.0: +decompress-response@^4.2.0: version "4.2.1" - resolved "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz#007f55cc6a62c055afa37c07eb6a4ee1b773f118" - integrity sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ== + resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" + integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== dependencies: - decompress-tar "^4.0.0" - decompress-tarbz2 "^4.0.0" - decompress-targz "^4.0.0" - decompress-unzip "^4.0.1" - graceful-fs "^4.1.10" - make-dir "^1.0.0" - pify "^2.3.0" - strip-dirs "^2.0.0" + mimic-response "^2.0.0" + +decompress-response@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-5.0.0.tgz#7849396e80e3d1eba8cb2f75ef4930f76461cb0f" + integrity sha512-TLZWWybuxWgoW7Lykv+gq9xvzOsUjQ9tF09Tj6NSTYGMTCHNXzrPnD6Hi+TgZq19PyTAGH4Ll/NIM/eTGglnMw== + dependencies: + mimic-response "^2.0.0" + +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" dedent@^0.7.0: version "0.7.0" @@ -9337,6 +8942,11 @@ deepmerge@4.2.2, deepmerge@^4.2.2: resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== +default-branch@^1.0.8: + version "1.0.8" + resolved "https://registry.npmjs.org/default-branch/-/default-branch-1.0.8.tgz#0e2f36a90e3b0d9f73cdf8e02841364ed35b8b54" + integrity sha512-pViUZEnaxd/Hbu880MEXF7XqV8RJ1ssDlkvzx+woQhcKW8px3BrVDvwBGn09zRiKZ+gOLipK7ft5x3no+3vb8A== + default-gateway@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b" @@ -9357,6 +8967,11 @@ defer-to-connect@^1.0.1: resolved "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== +defer-to-connect@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.0.tgz#83d6b199db041593ac84d781b5222308ccf4c2c1" + integrity sha512-bYL2d05vOSf1JEZNx5vSAtPuBMkX8K9EUutg7zlKvTqKXHt7RhWJFbmd7qakVuf13i+IkGmp6FwSsONOf6VYIg== + define-properties@^1.1.2, define-properties@^1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" @@ -9520,11 +9135,6 @@ dezalgo@^1.0.0: asap "^2.0.0" wrappy "1" -diacritics-map@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/diacritics-map/-/diacritics-map-0.1.0.tgz#6dfc0ff9d01000a2edf2865371cac316e94977af" - integrity sha1-bfwP+dAQAKLt8oZTccrDFulJd68= - diagnostics@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.1.tgz#cab6ac33df70c9d9a727490ae43ac995a769b22a" @@ -9546,10 +9156,10 @@ diff-sequences@^25.2.6: resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd" integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg== -diff-sequences@^26.0.0: - version "26.0.0" - resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.0.0.tgz#0760059a5c287637b842bd7085311db7060e88a6" - integrity sha512-JC/eHYEC3aSS0vZGjuoc4vHA0yAQTzhQQldXMeMF+JlxLGJlCO38Gma82NV9gk1jGFz8mDzUMeaKXvjRRdJ2dg== +diff-sequences@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.3.0.tgz#62a59b1b29ab7fd27cef2a33ae52abe73042d0a2" + integrity sha512-5j5vdRcw3CNctePNYN0Wy2e/JbWT6cAYnXv5OuqPhDpyCGc0uLu2TK0zOCJWNB9kOIfYMSpIulRaDgIi4HJ6Ig== diff@^4.0.1, diff@^4.0.2: version "4.0.2" @@ -9617,16 +9227,7 @@ docker-modem@^2.1.0: split-ca "^1.0.1" ssh2 "^0.8.7" -dockerode@^3.2.0: - version "3.2.0" - resolved "https://registry.npmjs.org/dockerode/-/dockerode-3.2.0.tgz#80e5bd9c2a988bdde4c995ae4aa2584fa0166c23" - integrity sha512-C+y/W4Kks7YLBsfUOTMkk1IVilb4cdj+rE+UZ5hnE+rpcn2frSs7kX+6H8GteTqHcv8sln+GyxuP1qdno3IzIw== - dependencies: - concat-stream "~2.0.0" - docker-modem "^2.1.0" - tar-fs "~2.0.1" - -dockerode@^3.2.1: +dockerode@^3.2.0, dockerode@^3.2.1: version "3.2.1" resolved "https://registry.npmjs.org/dockerode/-/dockerode-3.2.1.tgz#4a2222e3e1df536bf595e78e76d3cfbf6d4d93b9" integrity sha512-XsSVB5Wu5HWMg1aelV5hFSqFJaKS5x1aiV/+sT7YOzOq1IRl49I/UwV8Pe4x6t0iF9kiGkWu5jwfvbkcFVupBw== @@ -9656,62 +9257,10 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -docusaurus@^1.14.4: - version "1.14.6" - resolved "https://registry.npmjs.org/docusaurus/-/docusaurus-1.14.6.tgz#ffab9f6dafe8c48c477e0ebc7f491e554143b2b5" - integrity sha512-Hpo6xqYIHwazwuhXW25AKYv/os+dWoJ87qql/m1j1xp83h/BnfYV2l8PA8zLggF1wGUbJQbTx7GWo6QvD8z+4Q== - dependencies: - "@babel/core" "^7.9.0" - "@babel/plugin-proposal-class-properties" "^7.8.3" - "@babel/plugin-proposal-object-rest-spread" "^7.9.0" - "@babel/polyfill" "^7.8.7" - "@babel/preset-env" "^7.9.0" - "@babel/preset-react" "^7.9.4" - "@babel/register" "^7.9.0" - "@babel/traverse" "^7.9.0" - "@babel/types" "^7.9.0" - autoprefixer "^9.7.5" - babylon "^6.18.0" - chalk "^3.0.0" - classnames "^2.2.6" - commander "^4.0.1" - crowdin-cli "^0.3.0" - cssnano "^4.1.10" - escape-string-regexp "^2.0.0" - express "^4.17.1" - feed "^4.0.0" - fs-extra "^8.1.0" - gaze "^1.1.3" - github-slugger "^1.2.1" - glob "^7.1.6" - highlight.js "^9.16.2" - imagemin "^6.0.0" - imagemin-gifsicle "^6.0.1" - imagemin-jpegtran "^6.0.0" - imagemin-optipng "^6.0.0" - imagemin-svgo "^7.0.0" - lodash "^4.17.15" - markdown-toc "^1.2.0" - mkdirp "^0.5.1" - portfinder "^1.0.25" - postcss "^7.0.23" - prismjs "^1.17.1" - react "^16.8.4" - react-dev-utils "^9.1.0" - react-dom "^16.8.4" - remarkable "^2.0.0" - request "^2.88.0" - shelljs "^0.8.4" - sitemap "^3.2.2" - tcp-port-used "^1.0.1" - tiny-lr "^1.1.1" - tree-node-cli "^1.2.5" - truncate-html "^1.0.3" - -dom-accessibility-api@^0.4.5: - version "0.4.5" - resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.4.5.tgz#d9c1cefa89f509d8cf132ab5d250004d755e76e3" - integrity sha512-HcPDilI95nKztbVikaN2vzwvmv0sE8Y2ZJFODy/m15n7mGXLeOKGiys9qWVbFbh+aq/KYj2lqMLybBOkYAEXqg== +dom-accessibility-api@^0.5.1: + version "0.5.2" + resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.2.tgz#ef3cdb5d3f0d599d8f9c8b18df2fb63c9793739d" + integrity sha512-k7hRNKAiPJXD2aBqfahSo4/01cTsKWXf+LqJgglnkN2Nz8TsxXKQBXHhKe0Ye9fEfHEZY49uSA5Sr3AqP/sWKA== dom-converter@^0.2: version "0.2.0" @@ -9720,7 +9269,7 @@ dom-converter@^0.2: dependencies: utila "~0.4" -dom-helpers@^5.0.0: +dom-helpers@^5.0.0, dom-helpers@^5.0.1: version "5.1.4" resolved "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.4.tgz#4609680ab5c79a45f2531441f1949b79d6587f4b" integrity sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A== @@ -9728,14 +9277,6 @@ dom-helpers@^5.0.0: "@babel/runtime" "^7.8.7" csstype "^2.6.7" -dom-helpers@^5.0.1: - version "5.1.3" - resolved "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.3.tgz#7233248eb3a2d1f74aafca31e52c5299cc8ce821" - integrity sha512-nZD1OtwfWGRBWlpANxacBEZrEuLa16o1nh7YopFWeoF68Zt8GGEmzHu6Xv4F3XaFIC+YXtTLrzgqKxFgLEe4jw== - dependencies: - "@babel/runtime" "^7.6.3" - csstype "^2.6.7" - dom-serializer@0, dom-serializer@^0.2.1: version "0.2.2" resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" @@ -9744,14 +9285,6 @@ dom-serializer@0, dom-serializer@^0.2.1: domelementtype "^2.0.1" entities "^2.0.0" -dom-serializer@~0.1.0: - version "0.1.1" - resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" - integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA== - dependencies: - domelementtype "^1.3.0" - entities "^1.1.1" - dom-walk@^0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018" @@ -9762,7 +9295,7 @@ domain-browser@^1.1.1: resolved "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== -domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1: +domelementtype@1, domelementtype@^1.3.1: version "1.3.1" resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== @@ -9898,41 +9431,6 @@ dotenv@^8.0.0: resolved "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== -download@^6.2.2: - version "6.2.5" - resolved "https://registry.npmjs.org/download/-/download-6.2.5.tgz#acd6a542e4cd0bb42ca70cfc98c9e43b07039714" - integrity sha512-DpO9K1sXAST8Cpzb7kmEhogJxymyVUd5qz/vCOSyvwtp2Klj2XcDt5YUuasgxka44SxF0q5RriKIwJmQHG2AuA== - dependencies: - caw "^2.0.0" - content-disposition "^0.5.2" - decompress "^4.0.0" - ext-name "^5.0.0" - file-type "5.2.0" - filenamify "^2.0.0" - get-stream "^3.0.0" - got "^7.0.0" - make-dir "^1.0.0" - p-event "^1.0.0" - pify "^3.0.0" - -download@^7.1.0: - version "7.1.0" - resolved "https://registry.npmjs.org/download/-/download-7.1.0.tgz#9059aa9d70b503ee76a132897be6dec8e5587233" - integrity sha512-xqnBTVd/E+GxJVrX5/eUJiLYjCGPwMpdL+jGhGU57BvtcA7wwhtHVbXBeUk51kOpW3S7Jn3BQbN9Q1R1Km2qDQ== - dependencies: - archive-type "^4.0.0" - caw "^2.0.1" - content-disposition "^0.5.2" - decompress "^4.2.0" - ext-name "^5.0.0" - file-type "^8.1.0" - filenamify "^2.0.0" - get-stream "^3.0.0" - got "^8.3.1" - make-dir "^1.2.0" - p-event "^2.1.0" - pify "^3.0.0" - duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -9973,17 +9471,14 @@ ee-first@1.1.1: resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -ejs@^2.7.4: - version "2.7.4" - resolved "https://registry.npmjs.org/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba" - integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA== +ejs@^3.1.2: + version "3.1.5" + resolved "https://registry.npmjs.org/ejs/-/ejs-3.1.5.tgz#aed723844dc20acb4b170cd9ab1017e476a0d93b" + integrity sha512-dldq3ZfFtgVTJMLjOe+/3sROTzALlL9E34V4/sDtUd/KlBSS0s6U1/+WPE1B4sj9CXHJpL1M6rhNJnc9Wbal9w== + dependencies: + jake "^10.6.1" -electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.378: - version "1.3.380" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.380.tgz#1e1f07091b42b54bccd0ad6d3a14f2b73b60dc9d" - integrity sha512-2jhQxJKcjcSpVOQm0NAfuLq8o+130blrcawoumdXT6411xG/xIAOyZodO/y7WTaYlz/NHe3sCCAe/cJLnDsqTw== - -electron-to-chromium@^1.3.488: +electron-to-chromium@^1.3.378, electron-to-chromium@^1.3.488: version "1.3.509" resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.509.tgz#830fcb89cd66dc2984d18d794973b99e3f00584c" integrity sha512-cN4lkjNRuTG8rtAqTOVgwpecEC2kbKA04PG6YijcKGHK/kD0xLjiqExcAOmLUwtXZRF8cBeam2I0VZcih919Ug== @@ -10018,6 +9513,11 @@ emitter-component@^1.1.1: resolved "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6" integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY= +emittery@^0.7.1: + version "0.7.1" + resolved "https://registry.npmjs.org/emittery/-/emittery-0.7.1.tgz#c02375a927a40948c0345cc903072597f5270451" + integrity sha512-d34LN4L6h18Bzz9xpoku2nPwKxCPlPMr3EEKTkoEBi+1/+b0lcRkRJ1UVyyZaKNeqGR3swcGl6s390DNO4YVgQ== + "emoji-regex@>=6.0.0 <=6.1.1": version "6.1.1" resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e" @@ -10078,10 +9578,19 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" -enhanced-resolve@^4.0.0, enhanced-resolve@^4.1.0: - version "4.1.1" - resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.1.1.tgz#2937e2b8066cd0fe7ce0990a98f0d71a35189f66" - integrity sha512-98p2zE+rL7/g/DzMHMTF4zZlCgeVdJ7yr6xzEpJRYwFYrGi9ANdn5DnJURg6RpBkyk60XYDnWIv51VfIhfNGuA== +endent@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/endent/-/endent-2.0.1.tgz#fb18383a3f37ae3213a5d9f6c4a880d1061eb4c5" + integrity sha512-mADztvcC+vCk4XEZaCz6xIPO2NHQuprv5CAEjuVAu6aZwqAj7nVNlMyl1goPFYqCCpS2OJV9jwpumJLkotZrNw== + dependencies: + dedent "^0.7.0" + fast-json-parse "^1.0.3" + objectorarray "^1.0.4" + +enhanced-resolve@^4.0.0, enhanced-resolve@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.3.0.tgz#3b806f3bfafc1ec7de69551ef93cca46c1704126" + integrity sha512-3e87LvavsdxyoCfGusJnrZ5G8SLPOFeHSNpZI/ATL9a5leXo2k0w6MKnbqhdBad9qTobSfB20Ld7UmgoNbAZkQ== dependencies: graceful-fs "^4.1.2" memory-fs "^0.5.0" @@ -10145,13 +9654,6 @@ error-stack-parser@^2.0.6: dependencies: stackframe "^1.1.1" -error@^7.0.0: - version "7.2.1" - resolved "https://registry.npmjs.org/error/-/error-7.2.1.tgz#eab21a4689b5f684fc83da84a0e390de82d94894" - integrity sha512-fo9HBvWnx3NGUKMvMwB/CBCMMrfEJgbDTVDEkPygA3Bdd3lM1OyCd+rbQ8BwnpF6GdVeOLDNmyL4N5Bg80ZvdA== - dependencies: - string-template "~0.2.1" - es-abstract@^1.17.0, es-abstract@^1.17.0-next.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.4: version "1.17.4" resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.4.tgz#e3aedf19706b20e7c2594c35fc0d57605a79e184" @@ -10274,29 +9776,17 @@ escape-html@^1.0.3, escape-html@~1.0.3: resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= -escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= - escape-string-regexp@2.0.0, escape-string-regexp@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== -escodegen@^1.14.1, escodegen@^1.9.1: - version "1.14.1" - resolved "https://registry.npmjs.org/escodegen/-/escodegen-1.14.1.tgz#ba01d0c8278b5e95a9a45350142026659027a457" - integrity sha512-Bmt7NcRySdIfNPfU2ZoXDrrXsG9ZjvDxcAlMfDUgRBjLOWTuIACXPBFJH7Z+cLb40JeQco5toikyc9t9P8E9SQ== - dependencies: - esprima "^4.0.1" - estraverse "^4.2.0" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.6.1" +escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= -escodegen@^1.6.1: +escodegen@^1.14.1, escodegen@^1.6.1, escodegen@^1.9.1: version "1.14.3" resolved "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw== @@ -10606,10 +10096,10 @@ event-stream@=3.3.4: stream-combiner "~0.0.4" through "~2.3.1" -eventemitter2@6.4.2: - version "6.4.2" - resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.2.tgz#f31f8b99d45245f0edbc5b00797830ff3b388970" - integrity sha512-r/Pwupa5RIzxIHbEKCkNXqpEQIIT4uQDxmP4G/Lug/NokVUWj0joz/WzWl3OxRpC5kDrH/WdiUJoR+IrwvXJEw== +eventemitter2@^6.4.2: + version "6.4.3" + resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" + integrity sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ== eventemitter3@^3.1.0: version "3.1.2" @@ -10641,35 +10131,11 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: md5.js "^1.3.4" safe-buffer "^5.1.1" -exec-buffer@^3.0.0: - version "3.2.0" - resolved "https://registry.npmjs.org/exec-buffer/-/exec-buffer-3.2.0.tgz#b1686dbd904c7cf982e652c1f5a79b1e5573082b" - integrity sha512-wsiD+2Tp6BWHoVv3B+5Dcx6E7u5zky+hUwOHjuH2hKSLR3dvRmX8fk8UD8uqQixHs4Wk6eDmiegVrMPjKj7wpA== - dependencies: - execa "^0.7.0" - p-finally "^1.0.0" - pify "^3.0.0" - rimraf "^2.5.4" - tempfile "^2.0.0" - exec-sh@^0.3.2: version "0.3.4" resolved "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.4.tgz#3a018ceb526cc6f6df2bb504b2bfe8e3a4934ec5" integrity sha512-sEFIkc61v75sWeOe72qyrqg2Qg0OuLESziUDk/O/z2qgS15y2gWVFrI6f2Qn/qw/0/NCfCEsmNA4zOjkwEZT1A== -execa@1.0.0, execa@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" - integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== - dependencies: - cross-spawn "^6.0.0" - get-stream "^4.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - execa@3.4.0: version "3.4.0" resolved "https://registry.npmjs.org/execa/-/execa-3.4.0.tgz#c08ed4550ef65d858fac269ffc8572446f37eb89" @@ -10686,13 +10152,13 @@ execa@3.4.0: signal-exit "^3.0.2" strip-final-newline "^2.0.0" -execa@^0.7.0: - version "0.7.0" - resolved "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" - integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c= +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== dependencies: - cross-spawn "^5.0.1" - get-stream "^3.0.0" + cross-spawn "^6.0.0" + get-stream "^4.0.0" is-stream "^1.1.0" npm-run-path "^2.0.0" p-finally "^1.0.0" @@ -10714,7 +10180,7 @@ execa@^4.0.0, execa@^4.0.1: signal-exit "^3.0.2" strip-final-newline "^2.0.0" -executable@4.1.1, executable@^4.1.0: +executable@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz#41532bff361d3e57af4d763b70582db18f5d133c" integrity sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg== @@ -10744,13 +10210,6 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" -expand-range@^1.8.1: - version "1.8.2" - resolved "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" - integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc= - dependencies: - fill-range "^2.1.0" - expand-tilde@^2.0.0, expand-tilde@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" @@ -10758,18 +10217,26 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -expect@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/expect/-/expect-26.0.1.tgz#18697b9611a7e2725e20ba3ceadda49bc9865421" - integrity sha512-QcCy4nygHeqmbw564YxNbHTJlXh47dVID2BUP52cZFpLU9zHViMFK6h07cC1wf7GYCTIigTdAXhVua8Yl1FkKg== +expect@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/expect/-/expect-26.4.2.tgz#36db120928a5a2d7d9736643032de32f24e1b2a1" + integrity sha512-IlJ3X52Z0lDHm7gjEp+m76uX46ldH5VpqmU0006vqDju/285twh7zaWMRhs67VpQhBwjjMchk+p5aA0VkERCAA== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" ansi-styles "^4.0.0" - jest-get-type "^26.0.0" - jest-matcher-utils "^26.0.1" - jest-message-util "^26.0.1" + jest-get-type "^26.3.0" + jest-matcher-utils "^26.4.2" + jest-message-util "^26.3.0" jest-regex-util "^26.0.0" +express-prom-bundle@^6.1.0: + version "6.1.0" + resolved "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-6.1.0.tgz#8fd72e5bedbbd686b8e7c49e0aecbc1b14b28743" + integrity sha512-krlvp5r6sgJ1IwL6M6/coMrNbAlwtpk+uyivfeyRMCupTK4HzIEQHH0gwrNhLiKyPmSbtZcSmtO6s+PRumRp5g== + dependencies: + on-finished "^2.3.0" + url-value-parser "^2.0.0" + express-promise-router@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/express-promise-router/-/express-promise-router-3.0.3.tgz#5e6d22a5a3f013d71833172fe8d7ab780c3f6b70" @@ -10815,21 +10282,6 @@ express@^4.0.0, express@^4.17.0, express@^4.17.1: utils-merge "1.0.1" vary "~1.1.2" -ext-list@^2.0.0: - version "2.2.2" - resolved "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz#0b98e64ed82f5acf0f2931babf69212ef52ddd37" - integrity sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA== - dependencies: - mime-db "^1.28.0" - -ext-name@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz#70781981d183ee15d13993c8822045c506c8f0a6" - integrity sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ== - dependencies: - ext-list "^2.0.0" - sort-keys-length "^1.0.0" - ext@^1.1.2: version "1.4.0" resolved "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz#89ae7a07158f79d35517882904324077e4379244" @@ -10885,7 +10337,7 @@ extract-files@^8.0.0: resolved "https://registry.npmjs.org/extract-files/-/extract-files-8.1.0.tgz#46a0690d0fe77411a2e3804852adeaa65cd59288" integrity sha512-PTGtfthZK79WUMk+avLmwx3NGdU8+iVFXC2NMGxKsn0MnihOG2lvumj+AZo8CTwTrwjXDgZ5tztbRlEdRjBonQ== -extract-zip@1.7.0: +extract-zip@^1.7.0: version "1.7.0" resolved "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA== @@ -10944,6 +10396,11 @@ fast-glob@^3.0.3, fast-glob@^3.1.1: micromatch "^4.0.2" picomatch "^2.2.1" +fast-json-parse@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/fast-json-parse/-/fast-json-parse-1.0.3.tgz#43e5c61ee4efa9265633046b770fb682a7577c4d" + integrity sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw== + fast-json-patch@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-2.2.1.tgz#18150d36c9ab65c7209e7d4eb113f4f8eaabe6d9" @@ -11002,7 +10459,7 @@ fault@^1.0.0, fault@^1.0.2: dependencies: format "^0.2.0" -faye-websocket@^0.10.0, faye-websocket@~0.10.0: +faye-websocket@^0.10.0: version "0.10.0" resolved "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" integrity sha1-TkkvjQTftviQA1B/btvy1QHnxvQ= @@ -11054,13 +10511,6 @@ fecha@^2.3.3: resolved "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd" integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg== -feed@^4.0.0: - version "4.2.1" - resolved "https://registry.npmjs.org/feed/-/feed-4.2.1.tgz#b246ef891051c7dbf088ca203341d9fb0444baee" - integrity sha512-l28KKcK1J/u3iq5dRDmmoB2p7dtBfACC2NqJh4dI2kFptxH0asfjmOfcxqh5Sv8suAlVa73gZJ4REY5RrafVvg== - dependencies: - xml-js "^1.6.11" - fetch-readablestream@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/fetch-readablestream/-/fetch-readablestream-0.2.0.tgz#eaa6d1a76b12de2d4731a343393c6ccdcfe2c795" @@ -11071,7 +10521,7 @@ figgy-pudding@^3.4.1, figgy-pudding@^3.5.1: resolved "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" integrity sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w== -figures@^1.3.5, figures@^1.7.0: +figures@^1.7.0: version "1.7.0" resolved "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4= @@ -11100,13 +10550,13 @@ file-entry-cache@^5.0.1: dependencies: flat-cache "^2.0.1" -file-loader@^4.2.0: - version "4.3.0" - resolved "https://registry.npmjs.org/file-loader/-/file-loader-4.3.0.tgz#780f040f729b3d18019f20605f723e844b8a58af" - integrity sha512-aKrYPYjF1yG3oX0kWRrqrSMfgftm7oJW5M+m4owoldH5C51C0RkIwB++JbRvEW3IU6/ZG5n8UvEcdgwOt2UOWA== +file-loader@^6.0.0: + version "6.1.0" + resolved "https://registry.npmjs.org/file-loader/-/file-loader-6.1.0.tgz#65b9fcfb0ea7f65a234a1f10cdd7f1ab9a33f253" + integrity sha512-26qPdHyTsArQ6gU4P1HJbAbnFTyT2r0pG7czh1GFAd9TZbj0n94wWbupgixZH/ET/meqi2/5+F7DhW4OAXD+Lg== dependencies: - loader-utils "^1.2.3" - schema-utils "^2.5.0" + loader-utils "^2.0.0" + schema-utils "^2.7.1" file-saver@eligrey/FileSaver.js#1.3.8: version "1.3.8" @@ -11121,36 +10571,6 @@ file-system-cache@^1.0.5: fs-extra "^0.30.0" ramda "^0.21.0" -file-type@5.2.0, file-type@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz#2ddbea7c73ffe36368dfae49dc338c058c2b8ad6" - integrity sha1-LdvqfHP/42No365J3DOMBYwritY= - -file-type@^10.4.0, file-type@^10.7.0: - version "10.11.0" - resolved "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz#2961d09e4675b9fb9a3ee6b69e9cd23f43fd1890" - integrity sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw== - -file-type@^3.8.0: - version "3.9.0" - resolved "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" - integrity sha1-JXoHg4TR24CHvESdEH1SpSZyuek= - -file-type@^4.2.0: - version "4.4.0" - resolved "https://registry.npmjs.org/file-type/-/file-type-4.4.0.tgz#1b600e5fca1fbdc6e80c0a70c71c8dba5f7906c5" - integrity sha1-G2AOX8ofvcboDApwxxyNul95BsU= - -file-type@^6.1.0: - version "6.2.0" - resolved "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz#e50cd75d356ffed4e306dc4f5bcf52a79903a919" - integrity sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg== - -file-type@^8.1.0: - version "8.1.0" - resolved "https://registry.npmjs.org/file-type/-/file-type-8.1.0.tgz#244f3b7ef641bbe0cca196c7276e4b332399f68c" - integrity sha512-qyQ0pzAy78gVoJsmYeNgl8uH8yKhr1lVhW7JbzJmnlRi0I4R2eEDEJZVKG8agpDnLpacwNbDhLNG/LMdxHD2YQ== - file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -11161,41 +10581,18 @@ filefy@0.1.10: resolved "https://registry.npmjs.org/filefy/-/filefy-0.1.10.tgz#174677c8e2fa5bc39a3af0ed6fb492f16b8fbf42" integrity sha512-VgoRVOOY1WkTpWH+KBy8zcU1G7uQTVsXqhWEgzryB9A5hg2aqCyZ6aQ/5PSzlqM5+6cnVrX6oYV0XqD3HZSnmQ== -filename-reserved-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz#abf73dfab735d045440abfea2d91f389ebbfa229" - integrity sha1-q/c9+rc10EVECr/qLZHzieu/oik= - -filenamify@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/filenamify/-/filenamify-2.1.0.tgz#88faf495fb1b47abfd612300002a16228c677ee9" - integrity sha512-ICw7NTT6RsDp2rnYKVd8Fu4cr6ITzGy3+u4vUujPkabyaz+03F24NWEX7fs5fp+kBonlaqPH8fAO2NM+SXt/JA== +filelist@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/filelist/-/filelist-1.0.1.tgz#f10d1a3ae86c1694808e8f20906f43d4c9132dbb" + integrity sha512-8zSK6Nu0DQIC08mUC46sWGXi+q3GGpKydAG36k+JDba6VRpkevvOWUW5a/PhShij4+vHT9M+ghgG7eM+a9JDUQ== dependencies: - filename-reserved-regex "^2.0.0" - strip-outer "^1.0.0" - trim-repeated "^1.0.0" - -filesize@3.6.1: - version "3.6.1" - resolved "https://registry.npmjs.org/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" - integrity sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg== + minimatch "^3.0.4" filesize@6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/filesize/-/filesize-6.0.1.tgz#f850b509909c7c86f7e450ea19006c31c2ed3d2f" integrity sha512-u4AYWPgbI5GBhs6id1KdImZWn5yfyFrrQ8OWZdN7ZMfA8Bf4HcO0BGo9bmUIEV8yrp8I1xVfJ/dn90GtFNNJcg== -fill-range@^2.1.0: - version "2.2.4" - resolved "https://registry.npmjs.org/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565" - integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q== - dependencies: - is-number "^2.1.0" - isobject "^2.0.0" - randomatic "^3.0.0" - repeat-element "^1.1.2" - repeat-string "^1.5.2" - fill-range@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" @@ -11235,7 +10632,7 @@ find-cache-dir@^2.0.0, find-cache-dir@^2.1.0: make-dir "^2.0.0" pkg-dir "^3.0.0" -find-cache-dir@^3.0.0, find-cache-dir@^3.2.0: +find-cache-dir@^3.2.0, find-cache-dir@^3.3.1: version "3.3.1" resolved "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880" integrity sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ== @@ -11249,13 +10646,6 @@ find-root@^1.1.0: resolved "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== -find-up@3.0.0, find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== - dependencies: - locate-path "^3.0.0" - find-up@4.1.0, find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -11279,7 +10669,14 @@ find-up@^2.0.0, find-up@^2.1.0: dependencies: locate-path "^2.0.0" -find-versions@^3.0.0, find-versions@^3.2.0: +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +find-versions@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/find-versions/-/find-versions-3.2.0.tgz#10297f98030a786829681690545ef659ed1d254e" integrity sha512-P8WRou2S+oe222TOCHitLy8zj+SIsVJh52VP4lvXkaFVnOFFdoWv1H1Jjvel1aI6NCFOAaeAVm8qrI0odiLcww== @@ -11339,11 +10736,6 @@ fn-name@~3.0.0: resolved "https://registry.npmjs.org/fn-name/-/fn-name-3.0.0.tgz#0596707f635929634d791f452309ab41558e3c5c" integrity sha512-eNMNr5exLoavuAMhIUVsOKF79SWd/zG104ef6sxBTSw+cZc6BXdQXDvYcGvp0VbxVVSp1XDUNoz7mg1xMtSznA== -focus-lock@^0.6.6: - version "0.6.6" - resolved "https://registry.npmjs.org/focus-lock/-/focus-lock-0.6.6.tgz#98119a755a38cfdbeda0280eaa77e307eee850c7" - integrity sha512-Dx69IXGCq1qsUExWuG+5wkiMqVM/zGx/reXSJSLogECwp3x6KeNQZ+NAetgxEFpnC41rD8U3+jRCW68+LNzdtw== - follow-redirects@1.5.10: version "1.5.10" resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" @@ -11351,12 +10743,10 @@ follow-redirects@1.5.10: dependencies: debug "=3.1.0" -follow-redirects@^1.0.0: - version "1.10.0" - resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.10.0.tgz#01f5263aee921c6a54fb91667f08f4155ce169eb" - integrity sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ== - dependencies: - debug "^3.0.0" +follow-redirects@^1.0.0, follow-redirects@^1.10.0: + version "1.13.0" + resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" + integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== for-in@^0.1.3: version "0.1.8" @@ -11392,20 +10782,6 @@ forever-agent@~0.6.1: resolved "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= -fork-ts-checker-webpack-plugin@1.5.0: - version "1.5.0" - resolved "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-1.5.0.tgz#ce1d77190b44d81a761b10b6284a373795e41f0c" - integrity sha512-zEhg7Hz+KhZlBhILYpXy+Beu96gwvkROWJiTXOCyOOMMrdBIRPvsBpBqgTI4jfJGrJXcqGwJR8zsBGDmzY0jsA== - dependencies: - babel-code-frame "^6.22.0" - chalk "^2.4.1" - chokidar "^2.0.4" - micromatch "^3.1.10" - minimatch "^3.0.4" - semver "^5.6.0" - tapable "^1.0.0" - worker-rpc "^0.1.0" - fork-ts-checker-webpack-plugin@3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-3.1.1.tgz#a1642c0d3e65f50c2cc1742e9c0a80f441f86b19" @@ -11420,7 +10796,7 @@ fork-ts-checker-webpack-plugin@3.1.1: tapable "^1.0.0" worker-rpc "^0.1.0" -fork-ts-checker-webpack-plugin@^4.0.5: +fork-ts-checker-webpack-plugin@^4.0.5, fork-ts-checker-webpack-plugin@^4.1.4: version "4.1.6" resolved "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.6.tgz#5055c703febcf37fa06405d400c122b905167fc5" integrity sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw== @@ -11487,7 +10863,7 @@ fresh@0.5.2: resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= -from2@^2.1.0, from2@^2.1.1: +from2@^2.1.0: version "2.3.0" resolved "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8= @@ -11510,7 +10886,7 @@ fs-constants@^1.0.0: resolved "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-extra@8.1.0, fs-extra@^8.0.1, fs-extra@^8.1.0: +fs-extra@8.1.0, fs-extra@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== @@ -11615,7 +10991,7 @@ functions-have-names@^1.2.0: resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.1.tgz#a981ac397fa0c9964551402cdc5533d7a4d52f91" integrity sha512-j48B/ZI7VKs3sgeI2cZp7WXWmZXu7Iq5pl5/vptV5N2mq+DGFuS/ulaDjtaoLpYzuD6u8UgrUKHfgo7fDTSiBA== -fuse.js@^3.4.6: +fuse.js@^3.6.1: version "3.6.1" resolved "https://registry.npmjs.org/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c" integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw== @@ -11634,13 +11010,6 @@ gauge@~2.7.3: strip-ansi "^3.0.1" wide-align "^1.1.0" -gaze@^1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz#c441733e13b927ac8c0ff0b4c3b033f28812924a" - integrity sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g== - dependencies: - globule "^1.0.0" - generic-names@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/generic-names/-/generic-names-2.0.1.tgz#f8a378ead2ccaa7a34f0317b05554832ae41b872" @@ -11707,13 +11076,6 @@ get-port@^5.1.1: resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== -get-proxy@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/get-proxy/-/get-proxy-2.1.0.tgz#349f2b4d91d44c4d4d4e9cba2ad90143fac5ef93" - integrity sha512-zmZIaQTWnNQb4R4fJUEp/FC51eZsc6EkErspy3xtIYStaq8EB/hDIWipxsal+E8rz0qD7f2sL/NA9Xee4RInJw== - dependencies: - npm-conf "^1.1.0" - get-stdin@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" @@ -11724,19 +11086,6 @@ get-stdin@^6.0.0: resolved "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b" integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g== -get-stream@3.0.0, get-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" - integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= - -get-stream@^2.2.0: - version "2.3.1" - resolved "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz#5f38f93f346009666ee0150a054167f91bdd95de" - integrity sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4= - dependencies: - object-assign "^4.0.1" - pinkie-promise "^2.0.0" - get-stream@^4.0.0, get-stream@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" @@ -11761,7 +11110,7 @@ getopts@2.2.5: resolved "https://registry.npmjs.org/getopts/-/getopts-2.2.5.tgz#67a0fe471cacb9c687d817cab6450b96dde8313b" integrity sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA== -getos@3.2.1: +getos@^3.2.1: version "3.2.1" resolved "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz#0134d1f4e00eb46144c5a9c0ac4dc087cbb27dc5" integrity sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q== @@ -11775,16 +11124,6 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -gifsicle@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/gifsicle/-/gifsicle-4.0.1.tgz#30e1e61e3ee4884ef702641b2e98a15c2127b2e2" - integrity sha512-A/kiCLfDdV+ERV/UB+2O41mifd+RxH8jlRG8DMxZO84Bma/Fw0htqZ+hY2iaalLRNyUu7tYZQslqUBJxBggxbg== - dependencies: - bin-build "^3.0.0" - bin-wrapper "^4.0.0" - execa "^1.0.0" - logalot "^2.0.0" - git-raw-commits@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.0.tgz#d92addf74440c14bcc5c83ecce3fb7f8a79118b5" @@ -11821,9 +11160,16 @@ git-up@^4.0.0: parse-url "^5.0.0" git-url-parse@^11.1.2: - version "11.1.2" - resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.1.2.tgz#aff1a897c36cc93699270587bea3dbcbbb95de67" - integrity sha512-gZeLVGY8QVKMIkckncX+iCq2/L8PlwncvDFKiWkBn9EtCfYDbliRTTp6qzyQ1VMdITUfq7293zDzfpjdiGASSQ== + version "11.1.3" + resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.1.3.tgz#03625b6fc09905e9ad1da7bb2b84be1bf9123143" + integrity sha512-GPsfwticcu52WQ+eHp0IYkAyaOASgYdtsQDIt4rUp6GbiNt1P9ddrh3O0kQB0eD4UJZszVqNT3+9Zwcg40fywA== + dependencies: + git-up "^4.0.0" + +git-url-parse@^11.2.0: + version "11.2.0" + resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.2.0.tgz#2955fd51befd6d96ea1389bbe2ef57e8e6042b04" + integrity sha512-KPoHZg8v+plarZvto4ruIzzJLFQoRx+sUs5DQSr07By9IBKguVd+e6jwrFR6/TP6xrCJlNV1tPqLO1aREc7O2g== dependencies: git-up "^4.0.0" @@ -11834,7 +11180,7 @@ gitconfiglocal@^1.0.0: dependencies: ini "^1.3.2" -github-slugger@^1.2.1, github-slugger@^1.3.0: +github-slugger@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" integrity sha512-gwJScWVNhFYSRDvURk/8yhcFBee6aFjye2a7Lhb2bUyRulpIoek9p0I9Kt7PT67d/nUlZbFu8L9RLiA0woQN8Q== @@ -11871,12 +11217,19 @@ glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0: dependencies: is-glob "^4.0.1" +glob-promise@^3.4.0: + version "3.4.0" + resolved "https://registry.npmjs.org/glob-promise/-/glob-promise-3.4.0.tgz#b6b8f084504216f702dc2ce8c9bc9ac8866fdb20" + integrity sha512-q08RJ6O+eJn+dVanerAndJwIcumgbDdYiUT7zFQl3Wm1xD6fBKtah7H8ZJChj4wP+8C+QfeVy8xautR7rdmKEw== + dependencies: + "@types/glob" "*" + glob-to-regexp@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= -glob@7.1.6, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@~7.1.1: +glob@7.1.6, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.1.6" resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== @@ -11970,7 +11323,7 @@ globby@11.0.1, globby@^11.0.0: merge2 "^1.3.0" slash "^3.0.0" -globby@8.0.2, globby@^8.0.1: +globby@8.0.2: version "8.0.2" resolved "https://registry.npmjs.org/globby/-/globby-8.0.2.tgz#5697619ccd95c5275dbb2d6faa42087c1a941d8d" integrity sha512-yTzMmKygLp8RUpG1Ymu2VXPSJQZjNAZPD4ywgYEaG7e4tBJeUQBO8OpXrf1RCNcEs5alsoJYPAMiIHP0cmeC7w== @@ -12034,15 +11387,6 @@ globby@^9.2.0: pify "^4.0.1" slash "^2.0.0" -globule@^1.0.0: - version "1.3.2" - resolved "https://registry.npmjs.org/globule/-/globule-1.3.2.tgz#d8bdd9e9e4eef8f96e245999a5dee7eb5d8529c4" - integrity sha512-7IDTQTIu2xzXkT+6mlluidnWo+BypnbSoEVVQCGfzqnl5Ik8d3e1d4wycb8Rj9tWW+Z39uPWsdlquqiqPCd/pA== - dependencies: - glob "~7.1.1" - lodash "~4.17.10" - minimatch "~3.0.2" - good-listener@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" @@ -12050,48 +11394,60 @@ good-listener@^1.2.2: dependencies: delegate "^3.1.2" -got@^7.0.0: - version "7.1.0" - resolved "https://registry.npmjs.org/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a" - integrity sha512-Y5WMo7xKKq1muPsxD+KmrR8DH5auG7fBdDVueZwETwV6VytKyU9OX/ddpq2/1hp1vIPvVb4T81dKQz3BivkNLw== +got@^10.7.0: + version "10.7.0" + resolved "https://registry.npmjs.org/got/-/got-10.7.0.tgz#62889dbcd6cca32cd6a154cc2d0c6895121d091f" + integrity sha512-aWTDeNw9g+XqEZNcTjMMZSy7B7yE9toWOFYip7ofFTLleJhvZwUxxTxkTpKvF+p1SAA4VHmuEy7PiHTHyq8tJg== dependencies: - decompress-response "^3.2.0" + "@sindresorhus/is" "^2.0.0" + "@szmarczak/http-timer" "^4.0.0" + "@types/cacheable-request" "^6.0.1" + cacheable-lookup "^2.0.0" + cacheable-request "^7.0.1" + decompress-response "^5.0.0" duplexer3 "^0.1.4" - get-stream "^3.0.0" - is-plain-obj "^1.1.0" - is-retry-allowed "^1.0.0" - is-stream "^1.0.0" - isurl "^1.0.0-alpha5" - lowercase-keys "^1.0.0" - p-cancelable "^0.3.0" - p-timeout "^1.1.1" - safe-buffer "^5.0.1" - timed-out "^4.0.0" - url-parse-lax "^1.0.0" - url-to-options "^1.0.1" + get-stream "^5.0.0" + lowercase-keys "^2.0.0" + mimic-response "^2.1.0" + p-cancelable "^2.0.0" + p-event "^4.0.0" + responselike "^2.0.0" + to-readable-stream "^2.0.0" + type-fest "^0.10.0" -got@^8.3.1: - version "8.3.2" - resolved "https://registry.npmjs.org/got/-/got-8.3.2.tgz#1d23f64390e97f776cac52e5b936e5f514d2e937" - integrity sha512-qjUJ5U/hawxosMryILofZCkm3C84PLJS/0grRIpjAwu+Lkxxj5cxeCU25BG0/3mDSpXKTyZr8oh8wIgLaH0QCw== +got@^11.1.4: + version "11.6.2" + resolved "https://registry.npmjs.org/got/-/got-11.6.2.tgz#79d7bb8c11df212b97f25565407a1f4ae73210ec" + integrity sha512-/21qgUePCeus29Jk7MEti8cgQUNXFSWfIevNIk4H7u1wmXNDrGPKPY6YsPY+o9CIT/a2DjCjRz0x1nM9FtS2/A== dependencies: - "@sindresorhus/is" "^0.7.0" - cacheable-request "^2.1.1" - decompress-response "^3.3.0" - duplexer3 "^0.1.4" - get-stream "^3.0.0" - into-stream "^3.1.0" - is-retry-allowed "^1.1.0" - isurl "^1.0.0-alpha5" - lowercase-keys "^1.0.0" - mimic-response "^1.0.0" - p-cancelable "^0.4.0" - p-timeout "^2.0.1" - pify "^3.0.0" - safe-buffer "^5.1.1" - timed-out "^4.0.1" - url-parse-lax "^3.0.0" - url-to-options "^1.0.1" + "@sindresorhus/is" "^3.1.1" + "@szmarczak/http-timer" "^4.0.5" + "@types/cacheable-request" "^6.0.1" + "@types/responselike" "^1.0.0" + cacheable-lookup "^5.0.3" + cacheable-request "^7.0.1" + decompress-response "^6.0.0" + http2-wrapper "^1.0.0-beta.5.2" + lowercase-keys "^2.0.0" + p-cancelable "^2.0.0" + responselike "^2.0.0" + +got@^11.5.2: + version "11.6.0" + resolved "https://registry.npmjs.org/got/-/got-11.6.0.tgz#4978c78f3cbc3a45ee95381f8bb6efd1db1f4638" + integrity sha512-ErhWb4IUjQzJ3vGs3+RR12NWlBDDkRciFpAkQ1LPUxi6OnwhGj07gQxjPsyIk69s7qMihwKrKquV6VQq7JNYLA== + dependencies: + "@sindresorhus/is" "^3.1.1" + "@szmarczak/http-timer" "^4.0.5" + "@types/cacheable-request" "^6.0.1" + "@types/responselike" "^1.0.0" + cacheable-lookup "^5.0.3" + cacheable-request "^7.0.1" + decompress-response "^6.0.0" + http2-wrapper "^1.0.0-beta.5.2" + lowercase-keys "^2.0.0" + p-cancelable "^2.0.0" + responselike "^2.0.0" got@^9.6.0: version "9.6.0" @@ -12110,11 +11466,16 @@ got@^9.6.0: to-readable-stream "^1.0.0" url-parse-lax "^3.0.0" -graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4: +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4: version "4.2.4" resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== + graphiql@^1.0.0-alpha.10: version "1.0.3" resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.0.3.tgz#f6b5d5c417d8f1a28786d3228a69883426ba74ad" @@ -12255,10 +11616,10 @@ graphql-upload@^8.0.2: http-errors "^1.7.3" object-path "^0.11.4" -graphql@15.1.0, graphql@^15.0.0: - version "15.1.0" - resolved "https://registry.npmjs.org/graphql/-/graphql-15.1.0.tgz#b93e28de805294ec08e1630d901db550cb8960a1" - integrity sha512-0TVyfOlCGhv/DBczQkJmwXOK6fjWkjzY3Pt7wY8i0gcYXq8aogG3weCsg48m72lywKSeOqedEHvVPOvZvSD51Q== +graphql@15.3.0, graphql@^15.0.0, graphql@^15.3.0: + version "15.3.0" + resolved "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz#3ad2b0caab0d110e3be4a5a9b2aa281e362b5278" + integrity sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w== graphql@^14.5.3: version "14.7.0" @@ -12267,22 +11628,6 @@ graphql@^14.5.3: dependencies: iterall "^1.2.2" -graphql@^15.3.0: - version "15.3.0" - resolved "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz#3ad2b0caab0d110e3be4a5a9b2aa281e362b5278" - integrity sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w== - -gray-matter@^2.1.0: - version "2.1.1" - resolved "https://registry.npmjs.org/gray-matter/-/gray-matter-2.1.1.tgz#3042d9adec2a1ded6a7707a9ed2380f8a17a430e" - integrity sha1-MELZrewqHe1qdwep7SOA+KF6Qw4= - dependencies: - ansi-red "^0.1.1" - coffee-script "^1.12.4" - extend-shallow "^2.0.1" - js-yaml "^3.8.1" - toml "^2.3.2" - growly@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" @@ -12293,15 +11638,6 @@ gud@^1.0.0: resolved "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" integrity sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw== -gulp-header@^1.7.1: - version "1.8.12" - resolved "https://registry.npmjs.org/gulp-header/-/gulp-header-1.8.12.tgz#ad306be0066599127281c4f8786660e705080a84" - integrity sha512-lh9HLdb53sC7XIZOYzTXM4lFuXElv3EVkSDhsd7DoJBj7hm+Ni7D3qYbb+Rr8DuM8nRanBvkVO9d7askreXGnQ== - dependencies: - concat-with-sourcemaps "*" - lodash.template "^4.4.0" - through2 "^2.0.0" - gzip-size@5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274" @@ -12367,23 +11703,11 @@ has-flag@^4.0.0: resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -has-symbol-support-x@^1.4.1: - version "1.4.2" - resolved "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455" - integrity sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw== - has-symbols@^1.0.0, has-symbols@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== -has-to-string-tag-x@^1.2.0: - version "1.4.1" - resolved "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz#a045ab383d7b4b2012a00148ab0aa5f290044d4d" - integrity sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw== - dependencies: - has-symbol-support-x "^1.4.1" - has-unicode@^2.0.0, has-unicode@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" @@ -12488,21 +11812,23 @@ highlight.js@^10.1.1, highlight.js@~10.1.0: resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.1.2.tgz#c20db951ba1c22c055010648dfffd7b2a968e00c" integrity sha512-Q39v/Mn5mfBlMff9r+zzA+gWxRsCRKwEMvYTiisLr/XUiFI/4puWt0Ojdko3R3JCNWGdOWaA5g/Yxqa23kC5AA== -highlight.js@^9.16.2: - version "9.18.3" - resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.18.3.tgz#a1a0a2028d5e3149e2380f8a865ee8516703d634" - integrity sha512-zBZAmhSupHIl5sITeMqIJnYCDfAEc3Gdkqj65wC1lpI468MMQeeQkhcIAvk+RylAkxrCcI9xy9piHiXeQ1BdzQ== - -highlight.js@~9.13.0: - version "9.13.1" - resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.13.1.tgz#054586d53a6863311168488a0f58d6c505ce641e" - integrity sha512-Sc28JNQNDzaH6PORtRLMvif9RSn1mYuOoX3omVjnb0+HbpPygU2ALBI0R/wsiqCb4/fcp07Gdo8g+fhtFrQl6A== - highlight.js@~9.15.0, highlight.js@~9.15.1: version "9.15.10" resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.10.tgz#7b18ed75c90348c045eef9ed08ca1319a2219ad2" integrity sha512-RoV7OkQm0T3os3Dd2VHLNMoaoDVx77Wygln3n9l5YV172XonWG6rgQD3XnF/BuFFZw9A0TJgmMSO8FEWQgvcXw== +history@^4.9.0: + version "4.10.1" + resolved "https://registry.npmjs.org/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" + integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== + dependencies: + "@babel/runtime" "^7.1.2" + loose-envify "^1.2.0" + resolve-pathname "^3.0.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + value-equal "^1.0.1" + history@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08" @@ -12519,7 +11845,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -12563,7 +11889,7 @@ hsla-regex@^1.0.0: resolved "https://registry.npmjs.org/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= -html-comment-regex@^1.1.0, html-comment-regex@^1.1.2: +html-comment-regex@^1.1.0: version "1.1.2" resolved "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== @@ -12615,10 +11941,10 @@ html-to-react@^1.3.4: lodash.camelcase "^4.3.0" ramda "^0.26" -html-webpack-plugin@^4.0.0-beta.2, html-webpack-plugin@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.3.0.tgz#53bf8f6d696c4637d5b656d3d9863d89ce8174fd" - integrity sha512-C0fzKN8yQoVLTelcJxZfJCE+aAvQiY2VUf3UuKrR4a9k5UMWYOtpDLsaXwATbcVCnI05hUS7L9ULQHWLZhyi3w== +html-webpack-plugin@^4.2.1, html-webpack-plugin@^4.3.0: + version "4.4.1" + resolved "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.4.1.tgz#61ab85aa1a84ba181443345ebaead51abbb84149" + integrity sha512-nEtdEIsIGXdXGG7MjTTZlmhqhpHU9pJFc1OYxcP36c5/ZKP6b0BJMww2QTvJGQYA9aMxUnjDujpZdYcVOXiBCQ== dependencies: "@types/html-minifier-terser" "^5.0.0" "@types/tapable" "^1.0.5" @@ -12637,7 +11963,7 @@ html2canvas@1.0.0-alpha.12: dependencies: css-line-break "1.0.1" -htmlparser2@^3.3.0, htmlparser2@^3.9.1: +htmlparser2@^3.3.0: version "3.10.1" resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== @@ -12659,7 +11985,7 @@ htmlparser2@^4.0, htmlparser2@^4.1.0: domutils "^2.0.0" entities "^2.0.0" -http-cache-semantics@3.8.1, http-cache-semantics@^3.8.1: +http-cache-semantics@^3.8.1: version "3.8.1" resolved "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2" integrity sha512-5ai2iksyV8ZXmnZhHH4rWPoxxistEexSi5936zIQ1bnNTW5VnA85B6P/VpXiRM017IgRvb2kKo1a//y+0wSp3w== @@ -12750,16 +12076,7 @@ http-proxy-middleware@^0.19.1: lodash "^4.17.11" micromatch "^3.1.10" -http-proxy@^1.17.0: - version "1.18.0" - resolved "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a" - integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ== - dependencies: - eventemitter3 "^4.0.0" - follow-redirects "^1.0.0" - requires-port "^1.0.0" - -http-proxy@^1.18.1: +http-proxy@^1.17.0, http-proxy@^1.18.1: version "1.18.1" resolved "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== @@ -12777,6 +12094,14 @@ http-signature@~1.2.0: jsprim "^1.2.2" sshpk "^1.7.0" +http2-wrapper@^1.0.0-beta.5.2: + version "1.0.0-beta.5.2" + resolved "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.0-beta.5.2.tgz#8b923deb90144aea65cf834b016a340fc98556f3" + integrity sha512-xYz9goEyBnC8XwXDTuC/MZ6t+MrKVQZOk4s7+PaDkwIsQd8IwqvM+0M6bA/2lvG8GHXcPdf+MejTUeO2LCPCeQ== + dependencies: + quick-lru "^5.1.1" + resolve-alpn "^1.0.0" + https-browserify@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" @@ -12803,14 +12128,14 @@ humanize-ms@^1.2.1: ms "^2.0.0" husky@^4.2.3: - version "4.2.5" - resolved "https://registry.npmjs.org/husky/-/husky-4.2.5.tgz#2b4f7622673a71579f901d9885ed448394b5fa36" - integrity sha512-SYZ95AjKcX7goYVZtVZF2i6XiZcHknw50iXvY7b0MiGoj5RwdgRQNEHdb+gPDPCXKlzwrybjFjkL6FOj8uRhZQ== + version "4.3.0" + resolved "https://registry.npmjs.org/husky/-/husky-4.3.0.tgz#0b2ec1d66424e9219d359e26a51c58ec5278f0de" + integrity sha512-tTMeLCLqSBqnflBZnlVDhpaIMucSGaYyX6855jM4AguGeWCeSzNdb1mfyWduTZ3pe3SJVvVWGL0jO1iKZVPfTA== dependencies: chalk "^4.0.0" ci-info "^2.0.0" compare-versions "^3.6.0" - cosmiconfig "^6.0.0" + cosmiconfig "^7.0.0" find-versions "^3.2.0" opencollective-postinstall "^2.0.2" pkg-dir "^4.2.0" @@ -12886,53 +12211,6 @@ ignore@^5.1.1, ignore@^5.1.4: resolved "https://registry.npmjs.org/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf" integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A== -imagemin-gifsicle@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/imagemin-gifsicle/-/imagemin-gifsicle-6.0.1.tgz#6abad4e95566d52e5a104aba1c24b4f3b48581b3" - integrity sha512-kuu47c6iKDQ6R9J10xCwL0lgs0+sMz3LRHqRcJ2CRBWdcNmo3T5hUaM8hSZfksptZXJLGKk8heSAvwtSdB1Fng== - dependencies: - exec-buffer "^3.0.0" - gifsicle "^4.0.0" - is-gif "^3.0.0" - -imagemin-jpegtran@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/imagemin-jpegtran/-/imagemin-jpegtran-6.0.0.tgz#c8d3bcfb6ec9c561c20a987142854be70d90b04f" - integrity sha512-Ih+NgThzqYfEWv9t58EItncaaXIHR0u9RuhKa8CtVBlMBvY0dCIxgQJQCfwImA4AV1PMfmUKlkyIHJjb7V4z1g== - dependencies: - exec-buffer "^3.0.0" - is-jpg "^2.0.0" - jpegtran-bin "^4.0.0" - -imagemin-optipng@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/imagemin-optipng/-/imagemin-optipng-6.0.0.tgz#a6bfc7b542fc08fc687e83dfb131249179a51a68" - integrity sha512-FoD2sMXvmoNm/zKPOWdhKpWdFdF9qiJmKC17MxZJPH42VMAp17/QENI/lIuP7LCUnLVAloO3AUoTSNzfhpyd8A== - dependencies: - exec-buffer "^3.0.0" - is-png "^1.0.0" - optipng-bin "^5.0.0" - -imagemin-svgo@^7.0.0: - version "7.1.0" - resolved "https://registry.npmjs.org/imagemin-svgo/-/imagemin-svgo-7.1.0.tgz#528a42fd3d55eff5d4af8fd1113f25fb61ad6d9a" - integrity sha512-0JlIZNWP0Luasn1HT82uB9nU9aa+vUj6kpT+MjPW11LbprXC+iC4HDwn1r4Q2/91qj4iy9tRZNsFySMlEpLdpg== - dependencies: - is-svg "^4.2.1" - svgo "^1.3.2" - -imagemin@^6.0.0: - version "6.1.0" - resolved "https://registry.npmjs.org/imagemin/-/imagemin-6.1.0.tgz#62508b465728fea36c03cdc07d915fe2d8cf9e13" - integrity sha512-8ryJBL1CN5uSHpiBMX0rJw79C9F9aJqMnjGnrd/1CafegpNuA81RBAAru/jQQEOWlOJJlpRnlcVFF6wq+Ist0A== - dependencies: - file-type "^10.7.0" - globby "^8.0.1" - make-dir "^1.0.0" - p-pipe "^1.1.0" - pify "^4.0.1" - replace-ext "^1.0.0" - immer@1.10.0: version "1.10.0" resolved "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" @@ -12997,11 +12275,6 @@ import-lazy@^2.1.0: resolved "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= -import-lazy@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/import-lazy/-/import-lazy-3.1.0.tgz#891279202c8a2280fdbd6674dbd8da1a1dfc67cc" - integrity sha512-8/gvXvX2JMn0F+CDlSC4l6kOmVaLOO3XLkksI7CI3Ud95KDYJuYur2b9P/PUt/i/pDAMd/DulQsNbbbmRRsDIQ== - import-local@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" @@ -13100,25 +12373,6 @@ inline-style-prefixer@^4.0.0: bowser "^1.7.3" css-in-js-utils "^2.0.0" -inquirer@6.5.0: - version "6.5.0" - resolved "https://registry.npmjs.org/inquirer/-/inquirer-6.5.0.tgz#2303317efc9a4ea7ec2e2df6f86569b734accf42" - integrity sha512-scfHejeG/lVZSpvCXpsB4j/wQNPM5JC8kiElOI0OUTwmc1RTpXr4H32/HOlQHcZiYl2z2VElwuCVDRG8vFmbnA== - dependencies: - ansi-escapes "^3.2.0" - chalk "^2.4.2" - cli-cursor "^2.1.0" - cli-width "^2.0.0" - external-editor "^3.0.3" - figures "^2.0.0" - lodash "^4.17.12" - mute-stream "0.0.7" - run-async "^2.2.0" - rxjs "^6.4.0" - string-width "^2.1.0" - strip-ansi "^5.1.0" - through "^2.3.6" - inquirer@7.0.4: version "7.0.4" resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.0.4.tgz#99af5bde47153abca23f5c7fc30db247f39da703" @@ -13138,7 +12392,7 @@ inquirer@7.0.4: strip-ansi "^5.1.0" through "^2.3.6" -inquirer@7.3.3: +inquirer@7.3.3, inquirer@^7.0.0, inquirer@^7.0.4: version "7.3.3" resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003" integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA== @@ -13176,25 +12430,6 @@ inquirer@^6.2.0: strip-ansi "^5.1.0" through "^2.3.6" -inquirer@^7.0.0, inquirer@^7.0.4: - version "7.2.0" - resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.2.0.tgz#63ce99d823090de7eb420e4bb05e6f3449aa389a" - integrity sha512-E0c4rPwr9ByePfNlTIB8z51kK1s2n6jrHuJeEHENl/sbq2G/S1auvibgEwNR4uSyiU+PiYHqSwsgGiXjG8p5ZQ== - dependencies: - ansi-escapes "^4.2.1" - chalk "^3.0.0" - cli-cursor "^3.1.0" - cli-width "^2.0.0" - external-editor "^3.0.3" - figures "^3.0.0" - lodash "^4.17.15" - mute-stream "0.0.8" - run-async "^2.4.0" - rxjs "^6.5.3" - string-width "^4.1.0" - strip-ansi "^6.0.0" - through "^2.3.6" - internal-ip@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz#845452baad9d2ca3b69c635a137acb9a0dad0907" @@ -13217,18 +12452,10 @@ interpret@^1.0.0: resolved "https://registry.npmjs.org/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== -interpret@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/interpret/-/interpret-2.0.0.tgz#b783ffac0b8371503e9ab39561df223286aa5433" - integrity sha512-e0/LknJ8wpMMhTiWcjivB+ESwIuvHnBSlBbmP/pSb8CQJldoj1p2qv7xGZ/+BtbTziYRFSz8OsvdbiX45LtYQA== - -into-stream@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/into-stream/-/into-stream-3.1.0.tgz#96fb0a936c12babd6ff1752a17d05616abd094c6" - integrity sha1-lvsKk2wSur1v8XUqF9BWFqvQlMY= - dependencies: - from2 "^2.1.1" - p-is-promise "^1.1.0" +interpret@^2.0.0, interpret@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" + integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4: version "2.2.4" @@ -13341,7 +12568,7 @@ is-callable@^1.1.4, is-callable@^1.1.5: resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q== -is-ci@2.0.0, is-ci@^2.0.0: +is-ci@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== @@ -13412,7 +12639,7 @@ is-docker@^2.0.0: resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz#2cb0df0e75e2d064fe1864c37cdeacb7b2dcf25b" integrity sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ== -is-dom@^1.0.9: +is-dom@^1.0.9, is-dom@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/is-dom/-/is-dom-1.1.0.tgz#af1fced292742443bb59ca3f76ab5e80907b4e8a" integrity sha512-u82f6mvhYxRPKpw8V1N0W8ce1xXwOrQtgGcxl6UCL5zBmZu3is/18K0rR7uFCnMDuAsS/3W54mGL4vsaFUQlEQ== @@ -13464,23 +12691,16 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-function@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/is-function/-/is-function-1.0.1.tgz#12cfb98b65b57dd3d193a3121f5f6e2f437602b5" - integrity sha1-Es+5i2W1fdPRk6MSH19uL0N2ArU= +is-function@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz#4f097f30abf6efadac9833b17ca5dc03f8144e08" + integrity sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ== is-generator-fn@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== -is-gif@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/is-gif/-/is-gif-3.0.0.tgz#c4be60b26a301d695bb833b20d9b5d66c6cf83b1" - integrity sha512-IqJ/jlbw5WJSNfwQ/lHEDXF8rxhRgF6ythk2oiEvhpG29F704eX9NO6TvPfMiq9DrbwgcEDnETYNcZDPewQoVw== - dependencies: - file-type "^10.4.0" - is-glob@4.0.1, is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" @@ -13512,7 +12732,7 @@ is-in-browser@^1.0.2, is-in-browser@^1.1.3: resolved "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" integrity sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU= -is-installed-globally@0.3.2, is-installed-globally@^0.3.1: +is-installed-globally@^0.3.1, is-installed-globally@^0.3.2: version "0.3.2" resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== @@ -13525,11 +12745,6 @@ is-interactive@^1.0.0: resolved "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== -is-jpg@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/is-jpg/-/is-jpg-2.0.0.tgz#2e1997fa6e9166eaac0242daae443403e4ef1d97" - integrity sha1-LhmX+m6RZuqsAkLarkQ0A+TvHZc= - is-map@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1" @@ -13540,23 +12755,11 @@ is-module@^1.0.0: resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= -is-natural-number@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz#ab9d76e1db4ced51e35de0c72ebecf09f734cde8" - integrity sha1-q5124dtM7VHjXeDHLr7PCfc0zeg= - is-npm@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d" integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig== -is-number@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" - integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8= - dependencies: - kind-of "^3.0.2" - is-number@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" @@ -13564,11 +12767,6 @@ is-number@^3.0.0: dependencies: kind-of "^3.0.2" -is-number@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" - integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ== - is-number@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -13639,26 +12837,16 @@ is-plain-object@^3.0.0: dependencies: isobject "^4.0.0" -is-png@^1.0.0: - version "1.1.0" - resolved "https://registry.npmjs.org/is-png/-/is-png-1.1.0.tgz#d574b12bf275c0350455570b0e5b57ab062077ce" - integrity sha1-1XSxK/J1wDUEVVcLDltXqwYgd84= - is-potential-custom-element-name@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397" integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c= -is-promise@^2.1: +is-promise@^2.1, is-promise@^2.1.0: version "2.2.2" resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== -is-promise@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" - integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o= - is-reference@^1.1.2: version "1.1.4" resolved "https://registry.npmjs.org/is-reference/-/is-reference-1.1.4.tgz#3f95849886ddb70256a3e6d062b1a68c13c51427" @@ -13666,12 +12854,12 @@ is-reference@^1.1.2: dependencies: "@types/estree" "0.0.39" -is-regex@^1.0.4, is-regex@^1.0.5: - version "1.0.5" - resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae" - integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ== +is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" + integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== dependencies: - has "^1.0.3" + has-symbols "^1.0.1" is-regexp@^1.0.0: version "1.0.0" @@ -13690,11 +12878,6 @@ is-resolvable@^1.0.0: resolved "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== -is-retry-allowed@^1.0.0, is-retry-allowed@^1.1.0: - version "1.2.0" - resolved "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4" - integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg== - is-root@2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" @@ -13712,7 +12895,7 @@ is-ssh@^1.3.0: dependencies: protocols "^1.1.0" -is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0: +is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= @@ -13734,13 +12917,6 @@ is-svg@^3.0.0: dependencies: html-comment-regex "^1.1.0" -is-svg@^4.2.1: - version "4.2.1" - resolved "https://registry.npmjs.org/is-svg/-/is-svg-4.2.1.tgz#095b496e345fec9211c2a7d5d021003e040d6f81" - integrity sha512-PHx3ANecKsKNl5y5+Jvt53Y4J7MfMpbNZkv384QNiswMKAWIbvcqbPz+sYbFKJI8Xv3be01GSFniPmoaP+Ai5A== - dependencies: - html-comment-regex "^1.1.2" - is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" @@ -13767,11 +12943,6 @@ is-unc-path@^1.0.0: dependencies: unc-path-regex "^0.1.2" -is-url@^1.2.2: - version "1.2.4" - resolved "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52" - integrity sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== - is-utf8@^0.2.0: version "0.2.1" resolved "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" @@ -13802,24 +12973,22 @@ is-wsl@^1.1.0: resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= -is-wsl@^2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-2.1.1.tgz#4a1c152d429df3d441669498e2486d3596ebaf1d" - integrity sha512-umZHcSrwlDHo2TGMXv0DZ8dIUGunZ2Iv68YZnrmCiBPkZ4aaOhtv7pXJKeki9k3qJ3RJr0cDyitcl5wEH3AYog== +is-wsl@^2.1.1, is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" is-yarn-global@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== -is2@2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/is2/-/is2-2.0.1.tgz#8ac355644840921ce435d94f05d3a94634d3481a" - integrity sha512-+WaJvnaA7aJySz2q/8sLjMb2Mw14KTplHmSwcSpZ/fWJPkUmqw3YTzSWbPJ7OAwRvdYTWF2Wg+yYJ1AdP5Z8CA== - dependencies: - deep-is "^0.1.3" - ip-regex "^2.1.0" - is-url "^1.2.2" +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" @@ -13878,15 +13047,12 @@ istanbul-lib-coverage@^3.0.0: resolved "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz#f5944a37c70b550b02a78a5c3b2055b280cec8ec" integrity sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg== -istanbul-lib-instrument@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.1.tgz#61f13ac2c96cfefb076fe7131156cc05907874e6" - integrity sha512-imIchxnodll7pvQBYOqUu88EufLCU56LMeFPZZM/fJZ1irYcYdqroaV+ACK1Ila8ls09iEYArp+nqyC6lW1Vfg== +istanbul-lib-instrument@^4.0.0, istanbul-lib-instrument@^4.0.3: + version "4.0.3" + resolved "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz#873c6fff897450118222774696a3f28902d77c1d" + integrity sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ== dependencies: "@babel/core" "^7.7.5" - "@babel/parser" "^7.7.5" - "@babel/template" "^7.7.4" - "@babel/traverse" "^7.7.4" "@istanbuljs/schema" "^0.1.2" istanbul-lib-coverage "^3.0.0" semver "^6.3.0" @@ -13917,14 +13083,6 @@ istanbul-reports@^3.0.2: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -isurl@^1.0.0-alpha5: - version "1.0.0" - resolved "https://registry.npmjs.org/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67" - integrity sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w== - dependencies: - has-to-string-tag-x "^1.2.0" - is-object "^1.0.1" - iterall@^1.1.3, iterall@^1.2.1, iterall@^1.2.2, iterall@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" @@ -13943,6 +13101,16 @@ iterate-value@^1.0.0: es-get-iterator "^1.0.2" iterate-iterator "^1.0.1" +jake@^10.6.1: + version "10.8.2" + resolved "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b" + integrity sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A== + dependencies: + async "0.9.x" + chalk "^2.4.2" + filelist "^1.0.1" + minimatch "^3.0.4" + jenkins@^0.28.0: version "0.28.0" resolved "https://registry.npmjs.org/jenkins/-/jenkins-0.28.0.tgz#72d6fcc452145403b34f6d4ecbd877ee1ab77fca" @@ -13950,57 +13118,57 @@ jenkins@^0.28.0: dependencies: papi "^0.29.0" -jest-changed-files@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.0.1.tgz#1334630c6a1ad75784120f39c3aa9278e59f349f" - integrity sha512-q8LP9Sint17HaE2LjxQXL+oYWW/WeeXMPE2+Op9X3mY8IEGFVc14xRxFjUuXUbcPAlDLhtWdIEt59GdQbn76Hw== +jest-changed-files@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.3.0.tgz#68fb2a7eb125f50839dab1f5a17db3607fe195b1" + integrity sha512-1C4R4nijgPltX6fugKxM4oQ18zimS7LqQ+zTTY8lMCMFPrxqBFb7KJH0Z2fRQJvw2Slbaipsqq7s1mgX5Iot+g== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" execa "^4.0.0" throat "^5.0.0" -jest-cli@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-cli/-/jest-cli-26.0.1.tgz#3a42399a4cbc96a519b99ad069a117d955570cac" - integrity sha512-pFLfSOBcbG9iOZWaMK4Een+tTxi/Wcm34geqZEqrst9cZDkTQ1LZ2CnBrTlHWuYAiTMFr0EQeK52ScyFU8wK+w== +jest-cli@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-cli/-/jest-cli-26.4.2.tgz#24afc6e4dfc25cde4c7ec4226fb7db5f157c21da" + integrity sha512-zb+lGd/SfrPvoRSC/0LWdaWCnscXc1mGYW//NP4/tmBvRPT3VntZ2jtKUONsRi59zc5JqmsSajA9ewJKFYp8Cw== dependencies: - "@jest/core" "^26.0.1" - "@jest/test-result" "^26.0.1" - "@jest/types" "^26.0.1" + "@jest/core" "^26.4.2" + "@jest/test-result" "^26.3.0" + "@jest/types" "^26.3.0" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" import-local "^3.0.2" is-ci "^2.0.0" - jest-config "^26.0.1" - jest-util "^26.0.1" - jest-validate "^26.0.1" + jest-config "^26.4.2" + jest-util "^26.3.0" + jest-validate "^26.4.2" prompts "^2.0.1" yargs "^15.3.1" -jest-config@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-config/-/jest-config-26.0.1.tgz#096a3d4150afadf719d1fab00e9a6fb2d6d67507" - integrity sha512-9mWKx2L1LFgOXlDsC4YSeavnblN6A4CPfXFiobq+YYLaBMymA/SczN7xYTSmLaEYHZOcB98UdoN4m5uNt6tztg== +jest-config@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-config/-/jest-config-26.4.2.tgz#da0cbb7dc2c131ffe831f0f7f2a36256e6086558" + integrity sha512-QBf7YGLuToiM8PmTnJEdRxyYy3mHWLh24LJZKVdXZ2PNdizSe1B/E8bVm+HYcjbEzGuVXDv/di+EzdO/6Gq80A== dependencies: "@babel/core" "^7.1.0" - "@jest/test-sequencer" "^26.0.1" - "@jest/types" "^26.0.1" - babel-jest "^26.0.1" + "@jest/test-sequencer" "^26.4.2" + "@jest/types" "^26.3.0" + babel-jest "^26.3.0" chalk "^4.0.0" deepmerge "^4.2.2" glob "^7.1.1" graceful-fs "^4.2.4" - jest-environment-jsdom "^26.0.1" - jest-environment-node "^26.0.1" - jest-get-type "^26.0.0" - jest-jasmine2 "^26.0.1" + jest-environment-jsdom "^26.3.0" + jest-environment-node "^26.3.0" + jest-get-type "^26.3.0" + jest-jasmine2 "^26.4.2" jest-regex-util "^26.0.0" - jest-resolve "^26.0.1" - jest-util "^26.0.1" - jest-validate "^26.0.1" + jest-resolve "^26.4.0" + jest-util "^26.3.0" + jest-validate "^26.4.2" micromatch "^4.0.2" - pretty-format "^26.0.1" + pretty-format "^26.4.2" jest-css-modules@^2.1.0: version "2.1.0" @@ -14019,15 +13187,15 @@ jest-diff@^25.1.0, jest-diff@^25.2.1: jest-get-type "^25.2.6" pretty-format "^25.5.0" -jest-diff@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.0.1.tgz#c44ab3cdd5977d466de69c46929e0e57f89aa1de" - integrity sha512-odTcHyl5X+U+QsczJmOjWw5tPvww+y9Yim5xzqxVl/R1j4z71+fHW4g8qu1ugMmKdFdxw+AtQgs5mupPnzcIBQ== +jest-diff@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.4.2.tgz#a1b7b303bcc534aabdb3bd4a7caf594ac059f5aa" + integrity sha512-6T1XQY8U28WH0Z5rGpQ+VqZSZz8EN8rZcBtfvXaOkbwxIEeRre6qnuZQlbY1AJ4MKDxQF8EkrCvK+hL/VkyYLQ== dependencies: chalk "^4.0.0" - diff-sequences "^26.0.0" - jest-get-type "^26.0.0" - pretty-format "^26.0.1" + diff-sequences "^26.3.0" + jest-get-type "^26.3.0" + pretty-format "^26.4.2" jest-docblock@^26.0.0: version "26.0.0" @@ -14036,39 +13204,41 @@ jest-docblock@^26.0.0: dependencies: detect-newline "^3.0.0" -jest-each@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-each/-/jest-each-26.0.1.tgz#633083061619302fc90dd8f58350f9d77d67be04" - integrity sha512-OTgJlwXCAR8NIWaXFL5DBbeS4QIYPuNASkzSwMCJO+ywo9BEa6TqkaSWsfR7VdbMLdgYJqSfQcIyjJCNwl5n4Q== +jest-each@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-each/-/jest-each-26.4.2.tgz#bb14f7f4304f2bb2e2b81f783f989449b8b6ffae" + integrity sha512-p15rt8r8cUcRY0Mvo1fpkOGYm7iI8S6ySxgIdfh3oOIv+gHwrHTy5VWCGOecWUhDsit4Nz8avJWdT07WLpbwDA== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" chalk "^4.0.0" - jest-get-type "^26.0.0" - jest-util "^26.0.1" - pretty-format "^26.0.1" + jest-get-type "^26.3.0" + jest-util "^26.3.0" + pretty-format "^26.4.2" -jest-environment-jsdom@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.0.1.tgz#217690852e5bdd7c846a4e3b50c8ffd441dfd249" - integrity sha512-u88NJa3aptz2Xix2pFhihRBAatwZHWwSiRLBDBQE1cdJvDjPvv7ZGA0NQBxWwDDn7D0g1uHqxM8aGgfA9Bx49g== +jest-environment-jsdom@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.3.0.tgz#3b749ba0f3a78e92ba2c9ce519e16e5dd515220c" + integrity sha512-zra8He2btIMJkAzvLaiZ9QwEPGEetbxqmjEBQwhH3CA+Hhhu0jSiEJxnJMbX28TGUvPLxBt/zyaTLrOPF4yMJA== dependencies: - "@jest/environment" "^26.0.1" - "@jest/fake-timers" "^26.0.1" - "@jest/types" "^26.0.1" - jest-mock "^26.0.1" - jest-util "^26.0.1" + "@jest/environment" "^26.3.0" + "@jest/fake-timers" "^26.3.0" + "@jest/types" "^26.3.0" + "@types/node" "*" + jest-mock "^26.3.0" + jest-util "^26.3.0" jsdom "^16.2.2" -jest-environment-node@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.0.1.tgz#584a9ff623124ff6eeb49e0131b5f7612b310b13" - integrity sha512-4FRBWcSn5yVo0KtNav7+5NH5Z/tEgDLp7VRQVS5tCouWORxj+nI+1tOLutM07Zb2Qi7ja+HEDoOUkjBSWZg/IQ== +jest-environment-node@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.3.0.tgz#56c6cfb506d1597f94ee8d717072bda7228df849" + integrity sha512-c9BvYoo+FGcMj5FunbBgtBnbR5qk3uky8PKyRVpSfe2/8+LrNQMiXX53z6q2kY+j15SkjQCOSL/6LHnCPLVHNw== dependencies: - "@jest/environment" "^26.0.1" - "@jest/fake-timers" "^26.0.1" - "@jest/types" "^26.0.1" - jest-mock "^26.0.1" - jest-util "^26.0.1" + "@jest/environment" "^26.3.0" + "@jest/fake-timers" "^26.3.0" + "@jest/types" "^26.3.0" + "@types/node" "*" + jest-mock "^26.3.0" + jest-util "^26.3.0" jest-esm-transformer@^1.0.0: version "1.0.0" @@ -14086,71 +13256,68 @@ jest-fetch-mock@^3.0.3: cross-fetch "^3.0.4" promise-polyfill "^8.1.3" -jest-get-type@^25.1.0: - version "25.1.0" - resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.1.0.tgz#1cfe5fc34f148dc3a8a3b7275f6b9ce9e2e8a876" - integrity sha512-yWkBnT+5tMr8ANB6V+OjmrIJufHtCAqI5ic2H40v+tRqxDmE0PGnIiTyvRWFOMtmVHYpwRqyazDbTnhpjsGvLw== - -jest-get-type@^25.2.6: +jest-get-type@^25.1.0, jest-get-type@^25.2.6: version "25.2.6" resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877" integrity sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig== -jest-get-type@^26.0.0: - version "26.0.0" - resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.0.0.tgz#381e986a718998dbfafcd5ec05934be538db4039" - integrity sha512-zRc1OAPnnws1EVfykXOj19zo2EMw5Hi6HLbFCSjpuJiXtOWAYIjNsHVSbpQ8bDX7L5BGYGI8m+HmKdjHYFF0kg== +jest-get-type@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" + integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== -jest-haste-map@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.0.1.tgz#40dcc03c43ac94d25b8618075804d09cd5d49de7" - integrity sha512-J9kBl/EdjmDsvyv7CiyKY5+DsTvVOScenprz/fGqfLg/pm1gdjbwwQ98nW0t+OIt+f+5nAVaElvn/6wP5KO7KA== +jest-haste-map@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.3.0.tgz#c51a3b40100d53ab777bfdad382d2e7a00e5c726" + integrity sha512-DHWBpTJgJhLLGwE5Z1ZaqLTYqeODQIZpby0zMBsCU9iRFHYyhklYqP4EiG73j5dkbaAdSZhgB938mL51Q5LeZA== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" "@types/graceful-fs" "^4.1.2" + "@types/node" "*" anymatch "^3.0.3" fb-watchman "^2.0.0" graceful-fs "^4.2.4" - jest-serializer "^26.0.0" - jest-util "^26.0.1" - jest-worker "^26.0.0" + jest-regex-util "^26.0.0" + jest-serializer "^26.3.0" + jest-util "^26.3.0" + jest-worker "^26.3.0" micromatch "^4.0.2" sane "^4.0.3" walker "^1.0.7" - which "^2.0.2" optionalDependencies: fsevents "^2.1.2" -jest-jasmine2@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.0.1.tgz#947c40ee816636ba23112af3206d6fa7b23c1c1c" - integrity sha512-ILaRyiWxiXOJ+RWTKupzQWwnPaeXPIoLS5uW41h18varJzd9/7I0QJGqg69fhTT1ev9JpSSo9QtalriUN0oqOg== +jest-jasmine2@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.4.2.tgz#18a9d5bec30904267ac5e9797570932aec1e2257" + integrity sha512-z7H4EpCldHN1J8fNgsja58QftxBSL+JcwZmaXIvV9WKIM+x49F4GLHu/+BQh2kzRKHAgaN/E82od+8rTOBPyPA== dependencies: "@babel/traverse" "^7.1.0" - "@jest/environment" "^26.0.1" - "@jest/source-map" "^26.0.0" - "@jest/test-result" "^26.0.1" - "@jest/types" "^26.0.1" + "@jest/environment" "^26.3.0" + "@jest/source-map" "^26.3.0" + "@jest/test-result" "^26.3.0" + "@jest/types" "^26.3.0" + "@types/node" "*" chalk "^4.0.0" co "^4.6.0" - expect "^26.0.1" + expect "^26.4.2" is-generator-fn "^2.0.0" - jest-each "^26.0.1" - jest-matcher-utils "^26.0.1" - jest-message-util "^26.0.1" - jest-runtime "^26.0.1" - jest-snapshot "^26.0.1" - jest-util "^26.0.1" - pretty-format "^26.0.1" + jest-each "^26.4.2" + jest-matcher-utils "^26.4.2" + jest-message-util "^26.3.0" + jest-runtime "^26.4.2" + jest-snapshot "^26.4.2" + jest-util "^26.3.0" + pretty-format "^26.4.2" throat "^5.0.0" -jest-leak-detector@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.0.1.tgz#79b19ab3f41170e0a78eb8fa754a116d3447fb8c" - integrity sha512-93FR8tJhaYIWrWsbmVN1pQ9ZNlbgRpfvrnw5LmgLRX0ckOJ8ut/I35CL7awi2ecq6Ca4lL59bEK9hr7nqoHWPA== +jest-leak-detector@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.4.2.tgz#c73e2fa8757bf905f6f66fb9e0070b70fa0f573f" + integrity sha512-akzGcxwxtE+9ZJZRW+M2o+nTNnmQZxrHJxX/HjgDaU5+PLmY1qnQPnMjgADPGCRPhB+Yawe1iij0REe+k/aHoA== dependencies: - jest-get-type "^26.0.0" - pretty-format "^26.0.1" + jest-get-type "^26.3.0" + pretty-format "^26.4.2" jest-matcher-utils@^25.1.0: version "25.1.0" @@ -14162,23 +13329,23 @@ jest-matcher-utils@^25.1.0: jest-get-type "^25.1.0" pretty-format "^25.1.0" -jest-matcher-utils@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.0.1.tgz#12e1fc386fe4f14678f4cc8dbd5ba75a58092911" - integrity sha512-PUMlsLth0Azen8Q2WFTwnSkGh2JZ8FYuwijC8NR47vXKpsrKmA1wWvgcj1CquuVfcYiDEdj985u5Wmg7COEARw== +jest-matcher-utils@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.4.2.tgz#fa81f3693f7cb67e5fc1537317525ef3b85f4b06" + integrity sha512-KcbNqWfWUG24R7tu9WcAOKKdiXiXCbMvQYT6iodZ9k1f7065k0keUOW6XpJMMvah+hTfqkhJhRXmA3r3zMAg0Q== dependencies: chalk "^4.0.0" - jest-diff "^26.0.1" - jest-get-type "^26.0.0" - pretty-format "^26.0.1" + jest-diff "^26.4.2" + jest-get-type "^26.3.0" + pretty-format "^26.4.2" -jest-message-util@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.0.1.tgz#07af1b42fc450b4cc8e90e4c9cef11b33ce9b0ac" - integrity sha512-CbK8uQREZ8umUfo8+zgIfEt+W7HAHjQCoRaNs4WxKGhAYBGwEyvxuK81FXa7VeB9pwDEXeeKOB2qcsNVCAvB7Q== +jest-message-util@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.3.0.tgz#3bdb538af27bb417f2d4d16557606fd082d5841a" + integrity sha512-xIavRYqr4/otGOiLxLZGj3ieMmjcNE73Ui+LdSW/Y790j5acqCsAdDiLIbzHCZMpN07JOENRWX5DcU+OQ+TjTA== dependencies: "@babel/code-frame" "^7.0.0" - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" "@types/stack-utils" "^1.0.1" chalk "^4.0.0" graceful-fs "^4.2.4" @@ -14186,190 +13353,188 @@ jest-message-util@^26.0.1: slash "^3.0.0" stack-utils "^2.0.2" -jest-mock@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-mock/-/jest-mock-26.0.1.tgz#7fd1517ed4955397cf1620a771dc2d61fad8fd40" - integrity sha512-MpYTBqycuPYSY6xKJognV7Ja46/TeRbAZept987Zp+tuJvMN0YBWyyhG9mXyYQaU3SBI0TUlSaO5L3p49agw7Q== +jest-mock@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/jest-mock/-/jest-mock-26.3.0.tgz#ee62207c3c5ebe5f35b760e1267fee19a1cfdeba" + integrity sha512-PeaRrg8Dc6mnS35gOo/CbZovoDPKAeB1FICZiuagAgGvbWdNNyjQjkOaGUa/3N3JtpQ/Mh9P4A2D4Fv51NnP8Q== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" + "@types/node" "*" -jest-pnp-resolver@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.1.tgz#ecdae604c077a7fbc70defb6d517c3c1c898923a" - integrity sha512-pgFw2tm54fzgYvc/OHrnysABEObZCUNFnhjoRjaVOCN8NYc032/gVjPaHD4Aq6ApkSieWtfKAFQtmDKAmhupnQ== +jest-pnp-resolver@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c" + integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== jest-regex-util@^26.0.0: version "26.0.0" resolved "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28" integrity sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A== -jest-resolve-dependencies@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.0.1.tgz#607ba7ccc32151d185a477cff45bf33bce417f0b" - integrity sha512-9d5/RS/ft0vB/qy7jct/qAhzJsr6fRQJyGAFigK3XD4hf9kIbEH5gks4t4Z7kyMRhowU6HWm/o8ILqhaHdSqLw== +jest-resolve-dependencies@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.4.2.tgz#739bdb027c14befb2fe5aabbd03f7bab355f1dc5" + integrity sha512-ADHaOwqEcVc71uTfySzSowA/RdxUpCxhxa2FNLiin9vWLB1uLPad3we+JSSROq5+SrL9iYPdZZF8bdKM7XABTQ== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" jest-regex-util "^26.0.0" - jest-snapshot "^26.0.1" + jest-snapshot "^26.4.2" -jest-resolve@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.0.1.tgz#21d1ee06f9ea270a343a8893051aeed940cde736" - integrity sha512-6jWxk0IKZkPIVTvq6s72RH735P8f9eCJW3IM5CX/SJFeKq1p2cZx0U49wf/SdMlhaB/anann5J2nCJj6HrbezQ== +jest-resolve@^26.4.0: + version "26.4.0" + resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.4.0.tgz#6dc0af7fb93e65b73fec0368ca2b76f3eb59a6d7" + integrity sha512-bn/JoZTEXRSlEx3+SfgZcJAVuTMOksYq9xe9O6s4Ekg84aKBObEaVXKOEilULRqviSLAYJldnoWV9c07kwtiCg== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" chalk "^4.0.0" graceful-fs "^4.2.4" - jest-pnp-resolver "^1.2.1" - jest-util "^26.0.1" + jest-pnp-resolver "^1.2.2" + jest-util "^26.3.0" read-pkg-up "^7.0.1" resolve "^1.17.0" slash "^3.0.0" -jest-runner@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-runner/-/jest-runner-26.0.1.tgz#ea03584b7ae4bacfb7e533d680a575a49ae35d50" - integrity sha512-CApm0g81b49Znm4cZekYQK67zY7kkB4umOlI2Dx5CwKAzdgw75EN+ozBHRvxBzwo1ZLYZ07TFxkaPm+1t4d8jA== +jest-runner@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-runner/-/jest-runner-26.4.2.tgz#c3ec5482c8edd31973bd3935df5a449a45b5b853" + integrity sha512-FgjDHeVknDjw1gRAYaoUoShe1K3XUuFMkIaXbdhEys+1O4bEJS8Avmn4lBwoMfL8O5oFTdWYKcf3tEJyyYyk8g== dependencies: - "@jest/console" "^26.0.1" - "@jest/environment" "^26.0.1" - "@jest/test-result" "^26.0.1" - "@jest/types" "^26.0.1" + "@jest/console" "^26.3.0" + "@jest/environment" "^26.3.0" + "@jest/test-result" "^26.3.0" + "@jest/types" "^26.3.0" + "@types/node" "*" chalk "^4.0.0" + emittery "^0.7.1" exit "^0.1.2" graceful-fs "^4.2.4" - jest-config "^26.0.1" + jest-config "^26.4.2" jest-docblock "^26.0.0" - jest-haste-map "^26.0.1" - jest-jasmine2 "^26.0.1" - jest-leak-detector "^26.0.1" - jest-message-util "^26.0.1" - jest-resolve "^26.0.1" - jest-runtime "^26.0.1" - jest-util "^26.0.1" - jest-worker "^26.0.0" + jest-haste-map "^26.3.0" + jest-leak-detector "^26.4.2" + jest-message-util "^26.3.0" + jest-resolve "^26.4.0" + jest-runtime "^26.4.2" + jest-util "^26.3.0" + jest-worker "^26.3.0" source-map-support "^0.5.6" throat "^5.0.0" -jest-runtime@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.0.1.tgz#a121a6321235987d294168e282d52b364d7d3f89" - integrity sha512-Ci2QhYFmANg5qaXWf78T2Pfo6GtmIBn2rRaLnklRyEucmPccmCKvS9JPljcmtVamsdMmkyNkVFb9pBTD6si9Lw== +jest-runtime@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.4.2.tgz#94ce17890353c92e4206580c73a8f0c024c33c42" + integrity sha512-4Pe7Uk5a80FnbHwSOk7ojNCJvz3Ks2CNQWT5Z7MJo4tX0jb3V/LThKvD9tKPNVNyeMH98J/nzGlcwc00R2dSHQ== dependencies: - "@jest/console" "^26.0.1" - "@jest/environment" "^26.0.1" - "@jest/fake-timers" "^26.0.1" - "@jest/globals" "^26.0.1" - "@jest/source-map" "^26.0.0" - "@jest/test-result" "^26.0.1" - "@jest/transform" "^26.0.1" - "@jest/types" "^26.0.1" + "@jest/console" "^26.3.0" + "@jest/environment" "^26.3.0" + "@jest/fake-timers" "^26.3.0" + "@jest/globals" "^26.4.2" + "@jest/source-map" "^26.3.0" + "@jest/test-result" "^26.3.0" + "@jest/transform" "^26.3.0" + "@jest/types" "^26.3.0" "@types/yargs" "^15.0.0" chalk "^4.0.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" glob "^7.1.3" graceful-fs "^4.2.4" - jest-config "^26.0.1" - jest-haste-map "^26.0.1" - jest-message-util "^26.0.1" - jest-mock "^26.0.1" + jest-config "^26.4.2" + jest-haste-map "^26.3.0" + jest-message-util "^26.3.0" + jest-mock "^26.3.0" jest-regex-util "^26.0.0" - jest-resolve "^26.0.1" - jest-snapshot "^26.0.1" - jest-util "^26.0.1" - jest-validate "^26.0.1" + jest-resolve "^26.4.0" + jest-snapshot "^26.4.2" + jest-util "^26.3.0" + jest-validate "^26.4.2" slash "^3.0.0" strip-bom "^4.0.0" yargs "^15.3.1" -jest-serializer@^26.0.0: - version "26.0.0" - resolved "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.0.0.tgz#f6c521ddb976943b93e662c0d4d79245abec72a3" - integrity sha512-sQGXLdEGWFAE4wIJ2ZaIDb+ikETlUirEOBsLXdoBbeLhTHkZUJwgk3+M8eyFizhM6le43PDCCKPA1hzkSDo4cQ== +jest-serializer@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.3.0.tgz#1c9d5e1b74d6e5f7e7f9627080fa205d976c33ef" + integrity sha512-IDRBQBLPlKa4flg77fqg0n/pH87tcRKwe8zxOVTWISxGpPHYkRZ1dXKyh04JOja7gppc60+soKVZ791mruVdow== dependencies: + "@types/node" "*" graceful-fs "^4.2.4" -jest-snapshot@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.0.1.tgz#1baa942bd83d47b837a84af7fcf5fd4a236da399" - integrity sha512-jxd+cF7+LL+a80qh6TAnTLUZHyQoWwEHSUFJjkw35u3Gx+BZUNuXhYvDqHXr62UQPnWo2P6fvQlLjsU93UKyxA== +jest-snapshot@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.4.2.tgz#87d3ac2f2bd87ea8003602fbebd8fcb9e94104f6" + integrity sha512-N6Uub8FccKlf5SBFnL2Ri/xofbaA68Cc3MGjP/NuwgnsvWh+9hLIR/DhrxbSiKXMY9vUW5dI6EW1eHaDHqe9sg== dependencies: "@babel/types" "^7.0.0" - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" "@types/prettier" "^2.0.0" chalk "^4.0.0" - expect "^26.0.1" + expect "^26.4.2" graceful-fs "^4.2.4" - jest-diff "^26.0.1" - jest-get-type "^26.0.0" - jest-matcher-utils "^26.0.1" - jest-message-util "^26.0.1" - jest-resolve "^26.0.1" - make-dir "^3.0.0" + jest-diff "^26.4.2" + jest-get-type "^26.3.0" + jest-haste-map "^26.3.0" + jest-matcher-utils "^26.4.2" + jest-message-util "^26.3.0" + jest-resolve "^26.4.0" natural-compare "^1.4.0" - pretty-format "^26.0.1" + pretty-format "^26.4.2" semver "^7.3.2" -jest-util@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-util/-/jest-util-26.0.1.tgz#72c4c51177b695fdd795ca072a6f94e3d7cef00a" - integrity sha512-byQ3n7ad1BO/WyFkYvlWQHTsomB6GIewBh8tlGtusiylAlaxQ1UpS0XYH0ngOyhZuHVLN79Qvl6/pMiDMSSG1g== +jest-util@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/jest-util/-/jest-util-26.3.0.tgz#a8974b191df30e2bf523ebbfdbaeb8efca535b3e" + integrity sha512-4zpn6bwV0+AMFN0IYhH/wnzIQzRaYVrz1A8sYnRnj4UXDXbOVtWmlaZkO9mipFqZ13okIfN87aDoJWB7VH6hcw== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" + "@types/node" "*" chalk "^4.0.0" graceful-fs "^4.2.4" is-ci "^2.0.0" - make-dir "^3.0.0" + micromatch "^4.0.2" -jest-validate@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-validate/-/jest-validate-26.0.1.tgz#a62987e1da5b7f724130f904725e22f4e5b2e23c" - integrity sha512-u0xRc+rbmov/VqXnX3DlkxD74rHI/CfS5xaV2VpeaVySjbb1JioNVOyly5b56q2l9ZKe7bVG5qWmjfctkQb0bA== +jest-validate@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/jest-validate/-/jest-validate-26.4.2.tgz#e871b0dfe97747133014dcf6445ee8018398f39c" + integrity sha512-blft+xDX7XXghfhY0mrsBCYhX365n8K5wNDC4XAcNKqqjEzsRUSXP44m6PL0QJEW2crxQFLLztVnJ4j7oPlQrQ== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" camelcase "^6.0.0" chalk "^4.0.0" - jest-get-type "^26.0.0" + jest-get-type "^26.3.0" leven "^3.1.0" - pretty-format "^26.0.1" + pretty-format "^26.4.2" -jest-watcher@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.0.1.tgz#5b5e3ebbdf10c240e22a98af66d645631afda770" - integrity sha512-pdZPydsS8475f89kGswaNsN3rhP6lnC3/QDCppP7bg1L9JQz7oU9Mb/5xPETk1RHDCWeqmVC47M4K5RR7ejxFw== +jest-watcher@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.3.0.tgz#f8ef3068ddb8af160ef868400318dc4a898eed08" + integrity sha512-XnLdKmyCGJ3VoF6G/p5ohbJ04q/vv5aH9ENI+i6BL0uu9WWB6Z7Z2lhQQk0d2AVZcRGp1yW+/TsoToMhBFPRdQ== dependencies: - "@jest/test-result" "^26.0.1" - "@jest/types" "^26.0.1" + "@jest/test-result" "^26.3.0" + "@jest/types" "^26.3.0" + "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" - jest-util "^26.0.1" + jest-util "^26.3.0" string-length "^4.0.1" -jest-worker@^25.1.0: - version "25.1.0" - resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-25.1.0.tgz#75d038bad6fdf58eba0d2ec1835856c497e3907a" - integrity sha512-ZHhHtlxOWSxCoNOKHGbiLzXnl42ga9CxDr27H36Qn+15pQZd3R/F24jrmjDelw9j/iHUIWMWs08/u2QN50HHOg== - dependencies: - merge-stream "^2.0.0" - supports-color "^7.0.0" - -jest-worker@^26.0.0: - version "26.0.0" - resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-26.0.0.tgz#4920c7714f0a96c6412464718d0c58a3df3fb066" - integrity sha512-pPaYa2+JnwmiZjK9x7p9BoZht+47ecFCDFA/CJxspHzeDvQcfVBLWzCiWyo+EGrSiQMWZtCFo9iSvMZnAAo8vw== +jest-worker@^26.2.1, jest-worker@^26.3.0: + version "26.3.0" + resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-26.3.0.tgz#7c8a97e4f4364b4f05ed8bca8ca0c24de091871f" + integrity sha512-Vmpn2F6IASefL+DVBhPzI2J9/GJUsqzomdeN+P+dK8/jKxbh8R3BtFnx3FIta7wYlPU62cpJMJQo4kuOowcMnw== dependencies: + "@types/node" "*" merge-stream "^2.0.0" supports-color "^7.0.0" jest@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/jest/-/jest-26.0.1.tgz#5c51a2e58dff7525b65f169721767173bf832694" - integrity sha512-29Q54kn5Bm7ZGKIuH2JRmnKl85YRigp0o0asTc6Sb6l2ch1DCXIeZTLLFy9ultJvhkTqbswF5DEx4+RlkmCxWg== + version "26.4.2" + resolved "https://registry.npmjs.org/jest/-/jest-26.4.2.tgz#7e8bfb348ec33f5459adeaffc1a25d5752d9d312" + integrity sha512-LLCjPrUh98Ik8CzW8LLVnSCfLaiY+wbK53U7VxnFSX7Q+kWC4noVeDvGWIFw0Amfq1lq2VfGm7YHWSLBV62MJw== dependencies: - "@jest/core" "^26.0.1" + "@jest/core" "^26.4.2" import-local "^3.0.2" - jest-cli "^26.0.1" + jest-cli "^26.4.2" jose@^1.27.1: version "1.27.1" @@ -14378,15 +13543,6 @@ jose@^1.27.1: dependencies: "@panva/asn1.js" "^1.0.0" -jpegtran-bin@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/jpegtran-bin/-/jpegtran-bin-4.0.0.tgz#d00aed809fba7aa6f30817e59eee4ddf198f8f10" - integrity sha512-2cRl1ism+wJUoYAYFt6O/rLBfpXNWG2dUWbgcEkTt5WGMnqI46eEro8T4C5zGROxKRqyKpCBSdHPvt5UYCtxaQ== - dependencies: - bin-build "^3.0.0" - bin-wrapper "^4.0.0" - logalot "^2.0.0" - js-cookie@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" @@ -14412,7 +13568,7 @@ js-tokens@^3.0.2: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@^3.10.0, js-yaml@^3.14.0, js-yaml@^3.8.1: +js-yaml@^3.10.0, js-yaml@^3.13.1, js-yaml@^3.14.0, js-yaml@^3.8.3: version "3.14.0" resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== @@ -14420,14 +13576,6 @@ js-yaml@^3.10.0, js-yaml@^3.14.0, js-yaml@^3.8.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^3.13.1, js-yaml@^3.8.3: - version "3.13.1" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" - integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - jsbn@~0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -14535,6 +13683,11 @@ json-buffer@3.0.0: resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + json-parse-better-errors@^1.0.0, json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" @@ -14563,14 +13716,19 @@ json-schema-merge-allof@^0.6.0: json-schema-compare "^0.2.2" lodash "^4.17.4" -json-schema-ref-parser@^7.1.0: - version "7.1.4" - resolved "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-7.1.4.tgz#abb3f2613911e9060dc2268477b40591753facf0" - integrity sha512-AD7bvav0vak1/63w3jH8F7eHId/4E4EPdMAEZhGxtjktteUv9dnNB/cJy6nVnMyoTPBJnLwFK6tiQPSTeleCtQ== +json-schema-migrate@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-0.2.0.tgz#ba47a5b0072fc72396460e1bd60b44d52178bbc6" + integrity sha1-ukelsAcvxyOWRg4b1gtE1SF4u8Y= dependencies: - call-me-maybe "^1.0.1" - js-yaml "^3.13.1" - ono "^6.0.0" + ajv "^5.0.0" + +json-schema-ref-parser@^9.0.6: + version "9.0.6" + resolved "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz#fc89a5e6b853f2abe8c0af30d3874196526adb60" + integrity sha512-z0JGv7rRD3CnJbZY/qCpscyArdtLJhr/wRBmFUdoZ8xMjsFyNdILSprG2degqRLjBjyhZHAEBpGOxniO9rKTxA== + dependencies: + "@apidevtools/json-schema-ref-parser" "9.0.6" json-schema-traverse@^0.3.0: version "0.3.1" @@ -14609,6 +13767,14 @@ json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= +json-to-ast@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/json-to-ast/-/json-to-ast-2.1.0.tgz#041a9fcd03c0845036acb670d29f425cea4faaf9" + integrity sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ== + dependencies: + code-error-fragment "0.0.230" + grapheme-splitter "^1.0.4" + json-to-pretty-yaml@1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/json-to-pretty-yaml/-/json-to-pretty-yaml-1.2.2.tgz#f4cd0bd0a5e8fe1df25aaf5ba118b099fd992d5b" @@ -14674,6 +13840,11 @@ jsonpointer@^4.0.1: resolved "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9" integrity sha1-T9kss04OnbPInIYi7PUfm5eMbLk= +jsonschema@^1.2.6: + version "1.2.6" + resolved "https://registry.npmjs.org/jsonschema/-/jsonschema-1.2.6.tgz#52b0a8e9dc06bbae7295249d03e4b9faee8a0c0b" + integrity sha512-SqhURKZG07JyKKeo/ir24QnS4/BV7a6gQy93bUSe4lUdNp0QNpIz2c9elWJQ9dpc5cQYY6cvCzgRwy0MQCLyqA== + jsonwebtoken@^8.1.0: version "8.5.1" resolved "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" @@ -14816,13 +13987,6 @@ jwt-decode@2.2.0: resolved "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79" integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk= -keyv@3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373" - integrity sha512-eguHnq22OE3uVoSYG0LVWNP+4ppamWr9+zWBe1bsNcovIMy6huUJFPgy4mGwCd/rnl3vOLGW1MTlu4c57CT1xA== - dependencies: - json-buffer "3.0.0" - keyv@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -14830,6 +13994,13 @@ keyv@^3.0.0: dependencies: json-buffer "3.0.0" +keyv@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/keyv/-/keyv-4.0.1.tgz#9fe703cb4a94d6d11729d320af033307efd02ee6" + integrity sha512-xz6Jv6oNkbhrFCvCP7HQa8AaII8y8LRpoSm661NOKLr4uHuBwhX4epXrPQgF3+xdJnN4Esm5X0xwY4bOlALOtw== + dependencies: + json-buffer "3.0.1" + killable@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" @@ -14879,25 +14050,25 @@ kleur@^3.0.3: integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== knex@^0.21.1: - version "0.21.1" - resolved "https://registry.npmjs.org/knex/-/knex-0.21.1.tgz#4fba7e6c58c9f459846c3090be157a732fc75e41" - integrity sha512-uWszXC2DPaLn/YznGT9wFTWUG9+kqbL4DMz+hCH789GLcLuYzq8werHPDKBJxtKvxrW/S1XIXgrTWdMypiVvsw== + version "0.21.5" + resolved "https://registry.npmjs.org/knex/-/knex-0.21.5.tgz#c4be1958488f348aed3510aa4b7115639ee1bd01" + integrity sha512-cQj7F2D/fu03eTr6ZzYCYKdB9w7fPYlvTiU/f2OeXay52Pq5PwD+NAkcf40WDnppt/4/4KukROwlMOaE7WArcA== dependencies: - colorette "1.1.0" + colorette "1.2.1" commander "^5.1.0" debug "4.1.1" esm "^3.2.25" getopts "2.2.5" inherits "~2.0.4" - interpret "^2.0.0" + interpret "^2.2.0" liftoff "3.1.0" - lodash "^4.17.15" + lodash "^4.17.20" mkdirp "^1.0.4" - pg-connection-string "2.2.0" + pg-connection-string "2.3.0" tarn "^3.0.0" tildify "2.0.0" uuid "^7.0.3" - v8flags "^3.1.3" + v8flags "^3.2.0" kuler@1.0.x: version "1.0.1" @@ -14913,7 +14084,7 @@ latest-version@^5.0.0: dependencies: package-json "^6.3.0" -lazy-ass@1.6.0: +lazy-ass@1.6.0, lazy-ass@^1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513" integrity sha1-eZllXoZGwX8In90YfRUNMyTVRRM= @@ -14928,13 +14099,6 @@ lazy-cache@^1.0.3: resolved "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" integrity sha1-odePw6UEdMuAhF07O24dpJpEbo4= -lazy-cache@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/lazy-cache/-/lazy-cache-2.0.2.tgz#b9190a4f913354694840859f8a8f7084d8822264" - integrity sha1-uRkKT5EzVGlIQIWfio9whNiCImQ= - dependencies: - set-getter "^0.1.0" - lazy-universal-dotenv@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/lazy-universal-dotenv/-/lazy-universal-dotenv-3.0.1.tgz#a6c8938414bca426ab8c9463940da451a911db38" @@ -15010,6 +14174,11 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +li@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b" + integrity sha1-IsWbyu+qmo7zWc91l4TkvxBq6hs= + liftoff@3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/liftoff/-/liftoff-3.1.0.tgz#c9ba6081f908670607ee79062d700df062c52ed3" @@ -15057,16 +14226,6 @@ lint-staged@^10.1.0: string-argv "0.3.1" stringify-object "^3.3.0" -list-item@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/list-item/-/list-item-1.1.1.tgz#0c65d00e287cb663ccb3cb3849a77e89ec268a56" - integrity sha1-DGXQDih8tmPMs8s4Sad+iewmilY= - dependencies: - expand-range "^1.8.1" - extend-shallow "^2.0.1" - is-number "^2.1.0" - repeat-string "^1.5.2" - listr-silent-renderer@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" @@ -15110,7 +14269,7 @@ listr2@^2.1.0: rxjs "^6.5.5" through "^2.3.8" -listr@0.14.3: +listr@0.14.3, listr@^0.14.3: version "0.14.3" resolved "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz#2fea909604e434be464c50bddba0d496928fa586" integrity sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA== @@ -15125,11 +14284,6 @@ listr@0.14.3: p-map "^2.0.0" rxjs "^6.3.3" -livereload-js@^2.3.0: - version "2.4.0" - resolved "https://registry.npmjs.org/livereload-js/-/livereload-js-2.4.0.tgz#447c31cf1ea9ab52fc20db615c5ddf678f78009c" - integrity sha512-XPQH8Z2GDP/Hwz2PCDrh2mth4yFejwA1OZ/81Ti3LgKyhDcEjsSsqFWZojHG0va/duGd+WyosY7eXLDoOyqcPw== - load-json-file@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" @@ -15186,7 +14340,7 @@ loader-utils@1.2.3: emojis-list "^2.0.0" json5 "^1.0.1" -loader-utils@^1.0.2, loader-utils@^1.1.0, loader-utils@^1.2.3: +loader-utils@^1.0.2, loader-utils@^1.1.0, loader-utils@^1.2.3, loader-utils@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== @@ -15242,47 +14396,22 @@ lodash.assign@^4.1.0, lodash.assign@^4.2.0: resolved "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" integrity sha1-DZnzzNem0mHRm9rrkkUAXShYCOc= -lodash.assignin@^4.0.9: - version "4.2.0" - resolved "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz#ba8df5fb841eb0a3e8044232b0e263a8dc6a28a2" - integrity sha1-uo31+4QesKPoBEIysOJjqNxqKKI= - -lodash.bind@^4.1.4: - version "4.2.1" - resolved "https://registry.npmjs.org/lodash.bind/-/lodash.bind-4.2.1.tgz#7ae3017e939622ac31b7d7d7dcb1b34db1690d35" - integrity sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU= - lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY= -lodash.chunk@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/lodash.chunk/-/lodash.chunk-4.2.0.tgz#66e5ce1f76ed27b4303d8c6512e8d1216e8106bc" - integrity sha1-ZuXOH3btJ7QwPYxlEujRIW6BBrw= - lodash.clonedeep@^4.5.0: version "4.5.0" resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= -lodash.debounce@^4, lodash.debounce@^4.0.8: +lodash.debounce@^4: version "4.0.8" resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= -lodash.defaults@^4.0.1: - version "4.2.0" - resolved "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" - integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= - -lodash.filter@^4.4.0: - version "4.6.0" - resolved "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz#668b1d4981603ae1cc5a6fa760143e480b4c4ace" - integrity sha1-ZosdSYFgOuHMWm+nYBQ+SAtMSs4= - -lodash.flatten@^4.2.0, lodash.flatten@^4.4.0: +lodash.flatten@^4.4.0: version "4.4.0" resolved "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= @@ -15292,11 +14421,6 @@ lodash.flattendeep@^4.0.0: resolved "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI= -lodash.foreach@^4.3.0: - version "4.5.0" - resolved "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" - integrity sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM= - lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" @@ -15337,62 +14461,27 @@ lodash.isstring@^4.0.1: resolved "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= -lodash.map@^4.4.0: - version "4.6.0" - resolved "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3" - integrity sha1-dx7Hg540c9nEzeKLGTlMNWL09tM= - lodash.memoize@4.x, lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= -lodash.merge@^4.4.0: - version "4.6.2" - resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" - integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== - lodash.once@^4.0.0, lodash.once@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= -lodash.padstart@^4.6.1: - version "4.6.1" - resolved "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz#d2e3eebff0d9d39ad50f5cbd1b52a7bce6bb611b" - integrity sha1-0uPuv/DZ05rVD1y9G1KnvOa7YRs= - -lodash.pick@^4.2.1: - version "4.4.0" - resolved "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" - integrity sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM= - -lodash.reduce@^4.4.0: - version "4.6.0" - resolved "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b" - integrity sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs= - -lodash.reject@^4.4.0: - version "4.6.0" - resolved "https://registry.npmjs.org/lodash.reject/-/lodash.reject-4.6.0.tgz#80d6492dc1470864bbf583533b651f42a9f52415" - integrity sha1-gNZJLcFHCGS79YNTO2UfQqn1JBU= - lodash.set@^4.3.2: version "4.3.2" resolved "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM= -lodash.some@^4.4.0: - version "4.6.0" - resolved "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d" - integrity sha1-G7nzFO9ri63tE7VJFpsqlF62jk0= - lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash.template@^4.0.2, lodash.template@^4.4.0, lodash.template@^4.5.0: +lodash.template@^4.0.2, lodash.template@^4.5.0: version "4.5.0" resolved "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab" integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A== @@ -15407,11 +14496,6 @@ lodash.templatesettings@^4.0.0: dependencies: lodash._reinterpolate "^3.0.0" -lodash.throttle@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" - integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= - lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" @@ -15427,18 +14511,16 @@ lodash@4.17.15: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.4, lodash@^4.2.1, lodash@~4.17.10, lodash@~4.17.15: +lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.2.1: + version "4.17.20" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + +lodash@~4.17.15: version "4.17.19" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== -log-symbols@3.0.0, log-symbols@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" - integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== - dependencies: - chalk "^2.4.2" - log-symbols@4.0.0, log-symbols@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920" @@ -15453,6 +14535,20 @@ log-symbols@^1.0.2: dependencies: chalk "^1.0.0" +log-symbols@^2.1.0: + version "2.2.0" + resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" + integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg== + dependencies: + chalk "^2.0.1" + +log-symbols@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" + integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== + dependencies: + chalk "^2.4.2" + log-update@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/log-update/-/log-update-2.3.0.tgz#88328fd7d1ce7938b29283746f0b1bc126b24708" @@ -15472,14 +14568,6 @@ log-update@^4.0.0: slice-ansi "^4.0.0" wrap-ansi "^6.2.0" -logalot@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/logalot/-/logalot-2.1.0.tgz#5f8e8c90d304edf12530951a5554abb8c5e3f552" - integrity sha1-X46MkNME7fElMJUaVVSruMXj9VI= - dependencies: - figures "^1.3.5" - squeak "^1.0.0" - logform@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/logform/-/logform-2.1.2.tgz#957155ebeb67a13164069825ce67ddb5bb2dd360" @@ -15496,17 +14584,20 @@ loglevel@^1.6.7, loglevel@^1.6.8: resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" integrity sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA== +loglevelnext@^1.0.1: + version "1.0.5" + resolved "https://registry.npmjs.org/loglevelnext/-/loglevelnext-1.0.5.tgz#36fc4f5996d6640f539ff203ba819641680d75a2" + integrity sha512-V/73qkPuJmx4BcBF19xPBr+0ZRVBhc4POxvZTZdMeXpJ4NItXSJ/MSwuFT0kQJlCbXvdlZoQQ/418bS1y9Jh6A== + dependencies: + es6-symbol "^3.1.1" + object.assign "^4.1.0" + long@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== -longest@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" - integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc= - -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -15528,11 +14619,6 @@ lower-case@2.0.1, lower-case@^2.0.1: dependencies: tslib "^1.10.0" -lowercase-keys@1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" - integrity sha1-TjNms55/VFfjXxMkvfb4jQv8cwY= - lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" @@ -15559,32 +14645,6 @@ lowlight@^1.14.0: fault "^1.0.0" highlight.js "~10.1.0" -lowlight@~1.11.0: - version "1.11.0" - resolved "https://registry.npmjs.org/lowlight/-/lowlight-1.11.0.tgz#1304d83005126d4e8b1dc0f07981e9b689ec2efc" - integrity sha512-xrGGN6XLL7MbTMdPD6NfWPwY43SNkjf/d0mecSx/CW36fUZTjRHEq0/Cdug3TWKtRXLWi7iMl1eP0olYxj/a4A== - dependencies: - fault "^1.0.2" - highlight.js "~9.13.0" - -lpad-align@^1.0.1: - version "1.1.2" - resolved "https://registry.npmjs.org/lpad-align/-/lpad-align-1.1.2.tgz#21f600ac1c3095c3c6e497ee67271ee08481fe9e" - integrity sha1-IfYArBwwlcPG5JfuZyce4ISB/p4= - dependencies: - get-stdin "^4.0.1" - indent-string "^2.1.0" - longest "^1.0.0" - meow "^3.3.0" - -lru-cache@^4.0.1: - version "4.1.5" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" - integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== - dependencies: - pseudomap "^1.0.2" - yallist "^2.1.2" - lru-cache@^5.0.0, lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -15592,6 +14652,13 @@ lru-cache@^5.0.0, lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + lru-queue@0.1: version "0.1.0" resolved "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" @@ -15611,7 +14678,7 @@ magic-string@^0.25.2: dependencies: sourcemap-codec "^1.4.4" -make-dir@^1.0.0, make-dir@^1.2.0: +make-dir@^1.0.0: version "1.3.0" resolved "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== @@ -15733,11 +14800,6 @@ markdown-it@^9.1.0: mdurl "^1.0.1" uc.micro "^1.0.5" -markdown-link@^0.1.1: - version "0.1.1" - resolved "https://registry.npmjs.org/markdown-link/-/markdown-link-0.1.1.tgz#32c5c65199a6457316322d1e4229d13407c8c7cf" - integrity sha1-MsXGUZmmRXMWMi0eQinRNAfIx88= - markdown-to-jsx@^6.11.4: version "6.11.4" resolved "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-6.11.4.tgz#b4528b1ab668aef7fe61c1535c27e837819392c5" @@ -15746,25 +14808,12 @@ markdown-to-jsx@^6.11.4: prop-types "^15.6.2" unquote "^1.1.0" -markdown-toc@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/markdown-toc/-/markdown-toc-1.2.0.tgz#44a15606844490314afc0444483f9e7b1122c339" - integrity sha512-eOsq7EGd3asV0oBfmyqngeEIhrbkc7XVP63OwcJBIhH2EpG2PzFcbZdhy1jutXSlRBBVMNXHvMtSr5LAxSUvUg== - dependencies: - concat-stream "^1.5.2" - diacritics-map "^0.1.0" - gray-matter "^2.1.0" - lazy-cache "^2.0.2" - list-item "^1.1.1" - markdown-link "^0.1.1" - minimist "^1.2.0" - mixin-deep "^1.1.3" - object.pick "^1.2.0" - remarkable "^1.7.1" - repeat-string "^1.6.1" - strip-color "^0.1.0" +material-colors@^1.2.1: + version "1.2.6" + resolved "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46" + integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg== -material-table@1.68.x: +material-table@1.68.0: version "1.68.0" resolved "https://registry.npmjs.org/material-table/-/material-table-1.68.0.tgz#275c3d9a885c40ae4bc5a7461c00e877f92397b9" integrity sha512-dyJJaVsS3m+i6sn71AvYcVdA1P9X1XiUOM2PekfvEeeMtkdQb66oChGkk77ndYi3Ja6j4DovGVNrgeVLwXLZiw== @@ -15782,11 +14831,6 @@ material-table@1.68.x: react-beautiful-dnd "^13.0.0" react-double-scrollbar "0.0.15" -math-random@^1.0.1: - version "1.0.4" - resolved "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" - integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A== - md5.js@^1.3.4: version "1.3.5" resolved "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -15990,12 +15034,7 @@ miller-rabin@^4.0.0: bn.js "^4.0.0" brorand "^1.0.1" -mime-db@1.43.0, "mime-db@>= 1.43.0 < 2": - version "1.43.0" - resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58" - integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ== - -mime-db@1.44.0, mime-db@^1.28.0: +mime-db@1.44.0, "mime-db@>= 1.43.0 < 2": version "1.44.0" resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== @@ -16012,14 +15051,7 @@ mime-types@2.1.18: dependencies: mime-db "~1.33.0" -mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: - version "2.1.26" - resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz#9c921fc09b7e149a65dfdc0da4d20997200b0a06" - integrity sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ== - dependencies: - mime-db "1.43.0" - -mime-types@^2.1.26: +mime-types@^2.1.12, mime-types@^2.1.26, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: version "2.1.27" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== @@ -16051,6 +15083,16 @@ mimic-response@^1.0.0, mimic-response@^1.0.1: resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== +mimic-response@^2.0.0, mimic-response@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" + integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== + +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + min-document@^2.19.0: version "2.19.0" resolved "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" @@ -16063,15 +15105,13 @@ min-indent@^1.0.0: resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= -mini-css-extract-plugin@^0.7.0: - version "0.7.0" - resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0" - integrity sha512-RQIw6+7utTYn8DBGsf/LpRgZCJMpZt+kuawJ/fju0KiOL6nAaTBNmCJwS7HtwSCXfS47gCkmtBFS7HdsquhdxQ== +mini-create-react-context@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz#df60501c83151db69e28eac0ef08b4002efab040" + integrity sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA== dependencies: - loader-utils "^1.1.0" - normalize-url "1.9.1" - schema-utils "^1.0.0" - webpack-sources "^1.1.0" + "@babel/runtime" "^7.5.5" + tiny-warning "^1.0.3" mini-css-extract-plugin@^0.9.0: version "0.9.0" @@ -16093,7 +15133,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: resolved "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= -minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2: +minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== @@ -16108,7 +15148,7 @@ minimist-options@^3.0.1: arrify "^1.0.1" is-plain-obj "^1.1.0" -minimist@1.2.5, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: +minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: version "1.2.5" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== @@ -16156,10 +15196,10 @@ minizlib@^1.2.1: dependencies: minipass "^2.9.0" -minizlib@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/minizlib/-/minizlib-2.1.0.tgz#fd52c645301ef09a63a2c209697c294c6ce02cf3" - integrity sha512-EzTZN/fjSvifSX0SlqUERCN39o6T40AMarPbv0MrarSFtIITCBh7bi+dU8nxGFHuqs9jdIAeoYoKuQAAASsPPA== +minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== dependencies: minipass "^3.0.0" yallist "^4.0.0" @@ -16185,7 +15225,7 @@ mitt@^1.1.2: resolved "https://registry.npmjs.org/mitt/-/mitt-1.2.0.tgz#cb24e6569c806e31bd4e3995787fe38a04fdf90d" integrity sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw== -mixin-deep@^1.1.3, mixin-deep@^1.2.0: +mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== @@ -16225,16 +15265,16 @@ mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.4, mkdirp@^0.5.5, mkdir dependencies: minimist "^1.2.5" +mock-fs@^4.13.0: + version "4.13.0" + resolved "https://registry.npmjs.org/mock-fs/-/mock-fs-4.13.0.tgz#31c02263673ec3789f90eb7b6963676aa407a598" + integrity sha512-DD0vOdofJdoaRNtnWcrXe6RQbpHkPPmtqGq14uRX0F8ZKJ5nv89CVTYl/BZdppDxBDaV0hl75htg3abpEWlPZA== + modify-values@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -moment@2.26.0: - version "2.26.0" - resolved "https://registry.npmjs.org/moment/-/moment-2.26.0.tgz#5e1f82c6bafca6e83e808b30c8705eed0dcbd39a" - integrity sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw== - moment@^2.25.3, moment@^2.26.0, moment@^2.27.0: version "2.27.0" resolved "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d" @@ -16292,7 +15332,7 @@ ms@^2.0.0, ms@^2.1.1: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -msw@^0.19.0, msw@^0.19.5: +msw@^0.19.5: version "0.19.5" resolved "https://registry.npmjs.org/msw/-/msw-0.19.5.tgz#7d2a1a852ccf1644d3db6735d69fff6777aac33f" integrity sha512-J5eQ++gDVZoHPC8gVXtWcakLjgmPipvFj/sEnlRV/WViXuiq2CamSqO3Wbh6H8bAmj+k2vUWCfcVT1HjMdKB2Q== @@ -16308,6 +15348,23 @@ msw@^0.19.0, msw@^0.19.5: statuses "^2.0.0" yargs "^15.3.1" +msw@^0.20.5: + version "0.20.5" + resolved "https://registry.npmjs.org/msw/-/msw-0.20.5.tgz#b6141080c0d8b17c451d9ca36c28cc47b4ac487a" + integrity sha512-hmEsey5BbVicMGt7aOh/GZ9ltga5N3tK6NiJXnbfCkAGKgnAVnjASr3i7Z+sWlZyY5uuMUFyLCEcqrlXxC6qIA== + dependencies: + "@open-draft/until" "^1.0.3" + "@types/cookie" "^0.4.0" + chalk "^4.1.0" + cookie "^0.4.1" + graphql "^15.3.0" + headers-utils "^1.2.0" + node-fetch "^2.6.0" + node-match-path "^0.4.4" + node-request-interceptor "^0.3.5" + statuses "^2.0.0" + yargs "^15.4.1" + multicast-dns-service-types@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" @@ -16350,12 +15407,7 @@ mz@^2.5.0, mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@^2.12.1: - version "2.14.0" - resolved "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" - integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== - -nan@^2.14.0: +nan@^2.12.1, nan@^2.14.0: version "2.14.1" resolved "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== @@ -16464,7 +15516,7 @@ node-fetch@2.1.2: resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz#ab884e8e7e57e38a944753cec706f788d1768bb5" integrity sha1-q4hOjn5X44qUR1POxwb3iNF2i7U= -node-fetch@2.6.0, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0, node-fetch@^2.6.0: +node-fetch@2.6.0: version "2.6.0" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== @@ -16477,6 +15529,11 @@ node-fetch@^1.0.1: encoding "^0.1.11" is-stream "^1.0.1" +node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0, node-fetch@^2.6.0: + version "2.6.1" + resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-forge@0.9.0: version "0.9.0" resolved "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" @@ -16555,26 +15612,26 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-match-path@^0.4.2: - version "0.4.2" - resolved "https://registry.npmjs.org/node-match-path/-/node-match-path-0.4.2.tgz#30cc39510fa493bff03c3d0d2fff711c868ec457" - integrity sha512-wfde4FOC5A8RTSUVZ7pTpBV+dJsr2vVxT6374VrNam6wnnhx6EvwAwL/E/r3AW/YU6XkeZggF5xfBlu4a/ULBg== +node-match-path@^0.4.2, node-match-path@^0.4.4: + version "0.4.4" + resolved "https://registry.npmjs.org/node-match-path/-/node-match-path-0.4.4.tgz#516a10926093c0cc6f237d020685b593b19baebb" + integrity sha512-pBq9gp7TG0r0VXuy/oeZmQsjBSnYQo7G886Ly/B3azRwZuEtHCY155dzmfoKWcDPGgyfIGD8WKVC7h3+6y7yTg== node-modules-regexp@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= -node-notifier@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/node-notifier/-/node-notifier-7.0.0.tgz#513bc42f2aa3a49fce1980a7ff375957c71f718a" - integrity sha512-y8ThJESxsHcak81PGpzWwQKxzk+5YtP3IxR8AYdpXQ1IB6FmcVzFdZXrkPin49F/DKUCfeeiziB8ptY9npzGuA== +node-notifier@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.0.tgz#a7eee2d51da6d0f7ff5094bc7108c911240c1620" + integrity sha512-46z7DUmcjoYdaWyXouuFNNfUo6eFa94t23c53c+lG/9Cvauk4a98rAUp9672X5dxGdQmLpPzTxzu8f/OeEPaFA== dependencies: growly "^1.3.0" - is-wsl "^2.1.1" - semver "^7.2.1" + is-wsl "^2.2.0" + semver "^7.3.2" shellwords "^0.1.1" - uuid "^7.0.3" + uuid "^8.3.0" which "^2.0.2" node-pre-gyp@^0.11.0: @@ -16609,14 +15666,7 @@ node-pre-gyp@^0.13.0: semver "^5.3.0" tar "^4" -node-releases@^1.1.29, node-releases@^1.1.52: - version "1.1.52" - resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.52.tgz#bcffee3e0a758e92e44ecfaecd0a47554b0bcba9" - integrity sha512-snSiT1UypkgGt2wxPqS6ImEUICbNCMb31yaxWrOLXjhlt2z2/IBpaOxzONExqSm4y5oLnAqjjRWu+wsDzK5yNQ== - dependencies: - semver "^6.3.0" - -node-releases@^1.1.58: +node-releases@^1.1.52, node-releases@^1.1.58: version "1.1.60" resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084" integrity sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA== @@ -16629,27 +15679,28 @@ node-request-interceptor@^0.2.5: debug "^4.1.1" headers-utils "^1.2.0" -nodegit-promise@~4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/nodegit-promise/-/nodegit-promise-4.0.0.tgz#5722b184f2df7327161064a791d2e842c9167b34" - integrity sha1-VyKxhPLfcycWEGSnkdLoQskWezQ= +node-request-interceptor@^0.3.5: + version "0.3.5" + resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.3.5.tgz#4b26159617829c9a70643012c0fdc3ae4c78ae43" + integrity sha512-lBwA3v00hxCW2xhhZHZ1ab5JMHoAnzgtbeaUXqTufRh7mpAG93ZOChj0btMDB1VZd+CKhCbtigsxCjmerKa2+w== dependencies: - asap "~2.0.3" + "@open-draft/until" "^1.0.3" + debug "^4.1.1" + headers-utils "^1.2.0" -nodegit@0.26.5: - version "0.26.5" - resolved "https://registry.npmjs.org/nodegit/-/nodegit-0.26.5.tgz#1534d8aaa52de7acfbbe18de28df2075de86f7d2" - integrity sha512-l9l2zhcJ0V7FYzPdXIsuJcXN8UnLuhQgM+377HJfCYE/eupL/OWtMVvUOq42F9dRsgC3bAYH9j2Xbwr0lpYVZQ== +nodegit@0.27.0, nodegit@^0.27.0: + version "0.27.0" + resolved "https://registry.npmjs.org/nodegit/-/nodegit-0.27.0.tgz#4e8cc236f60e1c97324a5acff99056fe116a6ebe" + integrity sha512-E9K4gPjWiA0b3Tx5lfWCzG7Cvodi2idl3V5UD2fZrOrHikIfrN7Fc2kWLtMUqqomyoToYJLeIC8IV7xb1CYRLA== dependencies: fs-extra "^7.0.0" + got "^10.7.0" json5 "^2.1.0" lodash "^4.17.14" nan "^2.14.0" node-gyp "^4.0.0" node-pre-gyp "^0.13.0" - promisify-node "~0.3.0" ramda "^0.25.0" - request-promise-native "^1.0.5" tar-fs "^1.16.3" nodemon@^2.0.2: @@ -16727,15 +15778,6 @@ normalize-url@1.9.1: query-string "^4.1.0" sort-keys "^1.0.0" -normalize-url@2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-2.0.1.tgz#835a9da1551fa26f70e92329069a23aa6574d7e6" - integrity sha512-D6MUW4K/VzoJ4rJ01JFKxDrtY1v9wrgzCX5f2qj/lzH1m/lW6MhUZFKerVsnyjOhOsYzI9Kqqak+10l4LvLpMw== - dependencies: - prepend-http "^2.0.0" - query-string "^5.0.1" - sort-keys "^2.0.0" - normalize-url@^3.0.0, normalize-url@^3.3.0: version "3.3.0" resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" @@ -16753,14 +15795,6 @@ npm-bundled@^1.0.1: dependencies: npm-normalize-package-bin "^1.0.1" -npm-conf@^1.1.0: - version "1.1.3" - resolved "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz#256cc47bd0e218c259c4e9550bf413bc2192aff9" - integrity sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw== - dependencies: - config-chain "^1.1.11" - pify "^3.0.0" - npm-lifecycle@^3.1.2: version "3.1.4" resolved "https://registry.npmjs.org/npm-lifecycle/-/npm-lifecycle-3.1.4.tgz#de6975c7d8df65f5150db110b57cce498b0b604c" @@ -16993,6 +16027,11 @@ object.values@^1.1.0, object.values@^1.1.1: function-bind "^1.1.1" has "^1.0.3" +objectorarray@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/objectorarray/-/objectorarray-1.0.4.tgz#d69b2f0ff7dc2701903d308bb85882f4ddb49483" + integrity sha512-91k8bjcldstRz1bG6zJo8lWD7c6QXcB4nTDUqiEvIL1xAsLoZlOOZZG+nd6YPz+V7zY1580J4Xxh1vZtyv4i/w== + obuf@^1.0.0, obuf@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" @@ -17008,7 +16047,7 @@ omggif@1.0.7: resolved "https://registry.npmjs.org/omggif/-/omggif-1.0.7.tgz#59d2eecb0263de84635b3feb887c0c9973f1e49d" integrity sha1-WdLuywJj3oRjWz/riHwMmXPx5J0= -on-finished@~2.3.0: +on-finished@^2.3.0, on-finished@~2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= @@ -17051,22 +16090,10 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" -ono@^6.0.0: - version "6.0.1" - resolved "https://registry.npmjs.org/ono/-/ono-6.0.1.tgz#1bc14ffb8af1e5db3f7397f75b88e4a2d64bbd71" - integrity sha512-5rdYW/106kHqLeG22GE2MHKq+FlsxMERZev9DCzQX1zwkxnFwBivSn5i17a5O/rDmOJOdf4Wyt80UZljzx9+DA== - -open@^6.3.0: - version "6.4.0" - resolved "https://registry.npmjs.org/open/-/open-6.4.0.tgz#5c13e96d0dc894686164f18965ecfe889ecfc8a9" - integrity sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg== - dependencies: - is-wsl "^1.1.0" - -open@^7.0.0, open@^7.0.2: - version "7.0.3" - resolved "https://registry.npmjs.org/open/-/open-7.0.3.tgz#db551a1af9c7ab4c7af664139930826138531c48" - integrity sha512-sP2ru2v0P290WFfv49Ap8MF6PkzGNnGlAwHweB4WR4mr5d2d0woiCluUeJ218w7/+PmoBy9JmYgD5A4mLcWOFA== +open@^7.0.2, open@^7.0.3: + version "7.2.1" + resolved "https://registry.npmjs.org/open/-/open-7.2.1.tgz#07b0ade11a43f2a8ce718480bdf3d7563a095195" + integrity sha512-xbYCJib4spUdmcs0g/2mK1nKo/jO2T7INClWd/beL7PFkXRWgr8B23ssDHX/USPn2M2IjDR5UdpYs6I67SnTSA== dependencies: is-docker "^2.0.0" is-wsl "^2.1.1" @@ -17114,15 +16141,6 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" -optipng-bin@^5.0.0: - version "5.1.0" - resolved "https://registry.npmjs.org/optipng-bin/-/optipng-bin-5.1.0.tgz#a7c7ab600a3ab5a177dae2f94c2d800aa386b5a9" - integrity sha512-9baoqZTNNmXQjq/PQTWEXbVV3AMO2sI/GaaqZJZ8SExfAzjijeAP7FEeT+TtyumSw7gr0PZtSUYB/Ke7iHQVKA== - dependencies: - bin-build "^3.0.0" - bin-wrapper "^4.0.0" - logalot "^2.0.0" - ora@*, ora@^4.0.3: version "4.0.4" resolved "https://registry.npmjs.org/ora/-/ora-4.0.4.tgz#e8da697cc5b6a47266655bf68e0fb588d29a545d" @@ -17149,13 +16167,6 @@ os-browserify@^0.3.0: resolved "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= -os-filter-obj@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/os-filter-obj/-/os-filter-obj-2.0.0.tgz#1c0b62d5f3a2442749a2d139e6dddee6e81d8d16" - integrity sha512-uksVLsqG3pVdzzPvmAHpBK0wKxYItuzZr7SziusRPoz67tGV8rL1szZ6IdeUrbqLjGDwApBtN29eEE3IqGHOjg== - dependencies: - arch "^2.1.0" - os-homedir@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" @@ -17189,44 +16200,37 @@ osenv@0, osenv@^0.1.4, osenv@^0.1.5: os-homedir "^1.0.0" os-tmpdir "^1.0.0" -ospath@1.2.2: +ospath@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b" integrity sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs= -p-cancelable@^0.3.0: - version "0.3.0" - resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa" - integrity sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw== - -p-cancelable@^0.4.0: - version "0.4.1" - resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.4.1.tgz#35f363d67d52081c8d9585e37bcceb7e0bbcb2a0" - integrity sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ== +overlayscrollbars@^1.10.2: + version "1.13.0" + resolved "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-1.13.0.tgz#1edb436328133b94877b558f77966d5497ca36a7" + integrity sha512-p8oHrMeRAKxXDMPI/EBNITj/zTVHKNnAnM59Im+xnoZUlV07FyTg46wom2286jJlXGGfcPFG/ba5NUiCwWNd4w== p-cancelable@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== +p-cancelable@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.0.0.tgz#4a3740f5bdaf5ed5d7c3e34882c6fb5d6b266a6e" + integrity sha512-wvPXDmbMmu2ksjkB4Z3nZWTSkJEb9lqVdMaCKpZUGJG9TMiNp9XcbG3fn9fPKjem04fJMJnXoyFPk2FmgiaiNg== + p-each-series@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48" integrity sha512-ZuRs1miPT4HrjFa+9fRfOFXxGJfORgelKV9f9nNOWw2gl6gVsRaVDOQP0+MI0G0wGKns1Yacsu0GjOFbTK0JFQ== -p-event@^1.0.0: - version "1.3.0" - resolved "https://registry.npmjs.org/p-event/-/p-event-1.3.0.tgz#8e6b4f4f65c72bc5b6fe28b75eda874f96a4a085" - integrity sha1-jmtPT2XHK8W2/ii3XtqHT5akoIU= +p-event@^4.0.0: + version "4.2.0" + resolved "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz#af4b049c8acd91ae81083ebd1e6f5cae2044c1b5" + integrity sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ== dependencies: - p-timeout "^1.1.1" - -p-event@^2.1.0: - version "2.3.1" - resolved "https://registry.npmjs.org/p-event/-/p-event-2.3.1.tgz#596279ef169ab2c3e0cae88c1cfbb08079993ef6" - integrity sha512-NQCqOFhbpVTMX4qMe8PF8lbGtzZ+LCiN7pcNrb/413Na7+TRoe1xkKUzuWa/YEJdGQ0FvKtj35EEbDoVPO2kbA== - dependencies: - p-timeout "^2.0.1" + p-timeout "^3.1.0" p-finally@^1.0.0: version "1.0.0" @@ -17238,12 +16242,7 @@ p-finally@^2.0.0: resolved "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw== -p-is-promise@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz#9c9456989e9f6588017b0434d56097675c3da05e" - integrity sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4= - -p-limit@3.0.2: +p-limit@3.0.2, p-limit@^3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.0.2.tgz#1664e010af3cadc681baafd3e2a437be7b0fb5fe" integrity sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg== @@ -17257,7 +16256,7 @@ p-limit@^1.1.0: dependencies: p-try "^1.0.0" -p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.2.2: +p-limit@^2.0.0, p-limit@^2.2.0: version "2.2.2" resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.2.2.tgz#61279b67721f5287aa1c13a9a7fbbc48c9291b1e" integrity sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ== @@ -17311,7 +16310,7 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" -p-pipe@^1.1.0, p-pipe@^1.2.0: +p-pipe@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/p-pipe/-/p-pipe-1.2.0.tgz#4b1a11399a11520a67790ee5a0c1d5881d6befe9" integrity sha1-SxoROZoRUgpneQ7loMHViB1r7+k= @@ -17343,20 +16342,6 @@ p-retry@^3.0.1: dependencies: retry "^0.12.0" -p-timeout@^1.1.1: - version "1.2.1" - resolved "https://registry.npmjs.org/p-timeout/-/p-timeout-1.2.1.tgz#5eb3b353b7fce99f101a1038880bb054ebbea386" - integrity sha1-XrOzU7f86Z8QGhA4iAuwVOu+o4Y= - dependencies: - p-finally "^1.0.0" - -p-timeout@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz#d8dd1979595d2dc0139e1fe46b8b646cb3cdf038" - integrity sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA== - dependencies: - p-finally "^1.0.0" - p-timeout@^3.1.0: version "3.2.0" resolved "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" @@ -17592,6 +16577,14 @@ passport-google-oauth20@^2.0.0: dependencies: passport-oauth2 "1.x.x" +passport-microsoft@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/passport-microsoft/-/passport-microsoft-0.1.0.tgz#dc72c1a38b294d74f4dc55fe93f52e25cb9aa5b4" + integrity sha512-0giBDgE1fnR5X84zJZkQ11hnKVrzEgViwRO6RGsormK9zTxFQmN/UHMTDbIpvhk989VqALewB6Pk1R5vNr3GHw== + dependencies: + passport-oauth2 "1.2.0" + pkginfo "0.2.x" + passport-oauth1@1.x.x: version "1.1.0" resolved "https://registry.npmjs.org/passport-oauth1/-/passport-oauth1-1.1.0.tgz#a7de988a211f9cf4687377130ea74df32730c918" @@ -17601,6 +16594,15 @@ passport-oauth1@1.x.x: passport-strategy "1.x.x" utils-merge "1.x.x" +passport-oauth2@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.2.0.tgz#49613a3eca85c7a1e65bf1019e2b6b80a10c8ac2" + integrity sha1-SWE6PsqFx6HmW/EBnitrgKEMisI= + dependencies: + oauth "0.9.x" + passport-strategy "1.x.x" + uid2 "0.0.x" + passport-oauth2@1.x.x, passport-oauth2@^1.4.0, passport-oauth2@^1.5.0: version "1.5.0" resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108" @@ -17730,6 +16732,13 @@ path-to-regexp@2.2.1: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz#90b617025a16381a879bc82a38d4e8bdeb2bcf45" integrity sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ== +path-to-regexp@^1.7.0: + version "1.8.0" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" + integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + dependencies: + isarray "0.0.1" + path-type@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -17801,12 +16810,7 @@ pg-connection-string@0.1.3, pg-connection-string@^0.1.3: resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7" integrity sha1-2hhHsglA5C7hSSvq9l1J2RskXfc= -pg-connection-string@2.2.0: - version "2.2.0" - resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.2.0.tgz#caab4d38a9de4fdc29c9317acceed752897de41c" - integrity sha512-xB/+wxcpFipUZOQcSzcgkjcNOosGhEoPSjz06jC89lv1dj7mc9bZv6wLVy8M2fVjP0a/xN0N988YDq1L0FhK3A== - -pg-connection-string@^2.3.0: +pg-connection-string@2.3.0, pg-connection-string@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.3.0.tgz#c13fcb84c298d0bfa9ba12b40dd6c23d946f55d6" integrity sha512-ukMTJXLI7/hZIwTW7hGMZJ0Lj0S2XQBCJ4Shv4y1zgQ/vqVea+FLhzywvPj0ujSuofu+yA4MYHGZPTsgjBgJ+w== @@ -17966,13 +16970,6 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkg-up@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f" - integrity sha1-yBmscoBZpGHKscOImivjxJoATX8= - dependencies: - find-up "^2.1.0" - pkg-up@3.1.0, pkg-up@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" @@ -17997,26 +16994,31 @@ pn@^1.1.0: resolved "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== -pnp-webpack-plugin@1.5.0: - version "1.5.0" - resolved "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.5.0.tgz#62a1cd3068f46d564bb33c56eb250e4d586676eb" - integrity sha512-jd9olUr9D7do+RN8Wspzhpxhgp1n6Vd0NtQ4SFkmIACZoEL1nkyAdW9Ygrinjec0vgDcWjscFQQ1gDW8rsfKTg== +pnp-webpack-plugin@1.6.4: + version "1.6.4" + resolved "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149" + integrity sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg== dependencies: - ts-pnp "^1.1.2" + ts-pnp "^1.1.6" -polished@^3.3.1: - version "3.5.0" - resolved "https://registry.npmjs.org/polished/-/polished-3.5.0.tgz#bffb0db79025c61d1f735f9bb77a379f5fc46345" - integrity sha512-TujkqjczBuuG8ObaNeq+zCCu46tTdaWxqtMxCGTxsV5NYTr9pL08H1P0jgy1V4PXLWm2UlIj+icSEwAZv7I8TA== +polished@^3.4.4: + version "3.6.5" + resolved "https://registry.npmjs.org/polished/-/polished-3.6.5.tgz#dbefdde64c675935ec55119fe2a2ab627ca82e9c" + integrity sha512-VwhC9MlhW7O5dg/z7k32dabcAFW1VI2+7fSe8cE/kXcfL7mVdoa5UxciYGW2sJU78ldDLT6+ROEKIZKFNTnUXQ== dependencies: - "@babel/runtime" "^7.8.7" + "@babel/runtime" "^7.9.2" -popper.js@^1.14.1, popper.js@^1.14.4, popper.js@^1.14.7: +popper.js@1.16.1-lts: + version "1.16.1-lts" + resolved "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz#cf6847b807da3799d80ee3d6d2f90df8a3f50b05" + integrity sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA== + +popper.js@^1.14.4, popper.js@^1.14.7: version "1.16.1" resolved "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== -portfinder@^1.0.25: +portfinder@^1.0.26: version "1.0.28" resolved "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA== @@ -18025,15 +17027,6 @@ portfinder@^1.0.25: debug "^3.1.1" mkdirp "^0.5.5" -portfinder@^1.0.26: - version "1.0.26" - resolved "https://registry.npmjs.org/portfinder/-/portfinder-1.0.26.tgz#475658d56ca30bed72ac7f1378ed350bd1b64e70" - integrity sha512-Xi7mKxJHHMI3rIUrnm/jjUgwhbYMkp/XKEcZX3aG4BrumLpq3nmoQMX+ClYnDZnZ/New7IatC1no5RX0zo1vXQ== - dependencies: - async "^2.6.2" - debug "^3.1.1" - mkdirp "^0.5.1" - posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" @@ -18414,7 +17407,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^ resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== -"postcss@5 - 7", postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.23, postcss@^7.0.26, postcss@^7.0.27, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: +"postcss@5 - 7", postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.26, postcss@^7.0.27, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: version "7.0.32" resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d" integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw== @@ -18478,7 +17471,7 @@ prelude-ls@~1.1.2: resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= -prepend-http@^1.0.0, prepend-http@^1.0.1: +prepend-http@^1.0.0: version "1.0.4" resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= @@ -18488,17 +17481,12 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= -prettier@^1.16.4: - version "1.19.1" - resolved "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" - integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== - -prettier@^2.0.5: +prettier@^2.0.5, prettier@~2.0.5: version "2.0.5" resolved "https://registry.npmjs.org/prettier/-/prettier-2.0.5.tgz#d6d56282455243f2f92cc1716692c08aa31522d4" integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg== -pretty-bytes@5.3.0: +pretty-bytes@^5.3.0: version "5.3.0" resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.3.0.tgz#f2849e27db79fb4d6cfe24764fc4134f165989f2" integrity sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg== @@ -18511,16 +17499,6 @@ pretty-error@^2.1.1: renderkid "^2.0.1" utila "~0.4" -pretty-format@^24.3.0: - version "24.9.0" - resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" - integrity sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA== - dependencies: - "@jest/types" "^24.9.0" - ansi-regex "^4.0.0" - ansi-styles "^3.2.0" - react-is "^16.8.4" - pretty-format@^25.1.0, pretty-format@^25.2.1, pretty-format@^25.5.0: version "25.5.0" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a" @@ -18531,12 +17509,12 @@ pretty-format@^25.1.0, pretty-format@^25.2.1, pretty-format@^25.5.0: ansi-styles "^4.0.0" react-is "^16.12.0" -pretty-format@^26.0.1: - version "26.0.1" - resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.0.1.tgz#a4fe54fe428ad2fd3413ca6bbd1ec8c2e277e197" - integrity sha512-SWxz6MbupT3ZSlL0Po4WF/KujhQaVehijR2blyRDCzk9e45EaYMVhMBn49fnRuHxtkSpXTes1GxNpVmH86Bxfw== +pretty-format@^26.4.2: + version "26.4.2" + resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.4.2.tgz#d081d032b398e801e2012af2df1214ef75a81237" + integrity sha512-zK6Gd8zDsEiVydOCGLkoBoZuqv8VTiHyAbKznXe/gaph/DAeZOmit9yMfgIz5adIgAMMs5XfoYSwAX3jcCO1tA== dependencies: - "@jest/types" "^26.0.1" + "@jest/types" "^26.3.0" ansi-regex "^5.0.0" ansi-styles "^4.0.0" react-is "^16.12.0" @@ -18575,20 +17553,13 @@ prisma-yml@1.34.10: scuid "^1.0.2" yaml-ast-parser "^0.0.40" -prismjs@^1.17.1: +prismjs@^1.21.0, prismjs@^1.8.4, prismjs@~1.21.0: version "1.21.0" resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.21.0.tgz#36c086ec36b45319ec4218ee164c110f9fc015a3" integrity sha512-uGdSIu1nk3kej2iZsLyDoJ7e9bnPzIgY0naW/HdknGj61zScaprVEVGHrPoXqI+M9sP0NDnTK2jpkvmldpuqDw== optionalDependencies: clipboard "^2.0.0" -prismjs@^1.20.0, prismjs@^1.8.4, prismjs@~1.20.0: - version "1.20.0" - resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.20.0.tgz#9b685fc480a3514ee7198eac6a3bf5024319ff03" - integrity sha512-AEDjSrVNkynnw6A+B1DsFkd6AVdTnp+/WoUixFRULlCLZVRZlVQMVWio/16jv7G1FscUxQxOQhWwApgbnxr6kQ== - optionalDependencies: - clipboard "^2.0.0" - prismjs@~1.17.0: version "1.17.1" resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.17.1.tgz#e669fcbd4cdd873c35102881c33b14d0d68519be" @@ -18616,6 +17587,13 @@ progress@^2.0.0: resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +prom-client@^12.0.0: + version "12.0.0" + resolved "https://registry.npmjs.org/prom-client/-/prom-client-12.0.0.tgz#9689379b19bd3f6ab88a9866124db9da3d76c6ed" + integrity sha512-JbzzHnw0VDwCvoqf8y1WDtq4wSBAbthMB1pcVI/0lzdqHGJI3KBJDXle70XK+c7Iv93Gihqo0a5LlOn+g8+DrQ== + dependencies: + tdigest "^0.1.1" + promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" @@ -18678,13 +17656,6 @@ promise@^8.0.3: dependencies: asap "~2.0.6" -promisify-node@~0.3.0: - version "0.3.0" - resolved "https://registry.npmjs.org/promisify-node/-/promisify-node-0.3.0.tgz#b4b55acf90faa7d2b8b90ca396899086c03060cf" - integrity sha1-tLVaz5D6p9K4uQyjlomQhsAwYM8= - dependencies: - nodegit-promise "~4.0.0" - prompts@^2.0.1: version "2.3.2" resolved "https://registry.npmjs.org/prompts/-/prompts-2.3.2.tgz#480572d89ecf39566d2bd3fe2c9fccb7c4c0b068" @@ -18758,11 +17729,6 @@ ps-tree@1.2.0: dependencies: event-stream "=3.3.4" -pseudomap@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" - integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= - psl@^1.1.28: version "1.8.0" resolved "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" @@ -18850,16 +17816,11 @@ qs@6.7.0: resolved "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== -qs@^6.4.0, qs@^6.9.4: +qs@^6.5.1, qs@^6.6.0, qs@^6.9.4: version "6.9.4" resolved "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== -qs@^6.5.1, qs@^6.6.0: - version "6.9.3" - resolved "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz#bfadcd296c2d549f1dffa560619132c977f5008e" - integrity sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw== - qs@~6.5.2: version "6.5.2" resolved "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -18873,14 +17834,14 @@ query-string@^4.1.0: object-assign "^4.1.0" strict-uri-encode "^1.0.0" -query-string@^5.0.1: - version "5.1.1" - resolved "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz#a78c012b71c17e05f2e3fa2319dd330682efb3cb" - integrity sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw== +query-string@^6.12.1: + version "6.13.1" + resolved "https://registry.npmjs.org/query-string/-/query-string-6.13.1.tgz#d913ccfce3b4b3a713989fe6d39466d92e71ccad" + integrity sha512-RfoButmcK+yCta1+FuU8REvisx1oEzhMKwhLUNcepQTPGcNMp1sIqjnfCtfnvGSQZQEhaBHvccujtWoUV3TTbA== dependencies: decode-uri-component "^0.2.0" - object-assign "^4.1.0" - strict-uri-encode "^1.0.0" + split-on-first "^1.0.0" + strict-uri-encode "^2.0.0" querystring-browser@^1.0.4: version "1.0.4" @@ -18912,6 +17873,11 @@ quick-lru@^4.0.1: resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +quick-lru@^5.1.1: + version "5.1.1" + resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" + integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== + raf-schd@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" @@ -18924,11 +17890,6 @@ raf@^3.1.0, raf@^3.4.1: dependencies: performance-now "^2.1.0" -ramda@0.26.1, ramda@^0.26: - version "0.26.1" - resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" - integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== - ramda@^0.21.0: version "0.21.0" resolved "https://registry.npmjs.org/ramda/-/ramda-0.21.0.tgz#a001abedb3ff61077d4ff1d577d44de77e8d0a35" @@ -18939,14 +17900,20 @@ ramda@^0.25.0: resolved "https://registry.npmjs.org/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" integrity sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ== -randomatic@^3.0.0: - version "3.1.1" - resolved "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed" - integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw== +ramda@^0.26, ramda@~0.26.1: + version "0.26.1" + resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" + integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== + +ramldt2jsonschema@^1.1.0: + version "1.2.3" + resolved "https://registry.npmjs.org/ramldt2jsonschema/-/ramldt2jsonschema-1.2.3.tgz#8d45a7f306a1169a3bde91cab725d1d6d0ff7d55" + integrity sha512-+wLDAV2NNv9NkfEUOYStaDu/6RYgYXeC1zLtXE+dMU/jDfjpN4iJnBGycDwFTFaIQGosOQhxph7fEX6Mpwxdug== dependencies: - is-number "^4.0.0" - kind-of "^6.0.0" - math-random "^1.0.1" + commander "^5.0.0" + js-yaml "^3.14.0" + json-schema-migrate "^0.2.0" + webapi-parser "^0.5.0" randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" @@ -18983,22 +17950,6 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" -raw-body@~1.1.0: - version "1.1.7" - resolved "https://registry.npmjs.org/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425" - integrity sha1-HQJ8K/oRasxmI7yo8AAWVyqH1CU= - dependencies: - bytes "1" - string_decoder "0.10" - -raw-loader@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/raw-loader/-/raw-loader-3.1.0.tgz#5e9d399a5a222cc0de18f42c3bc5e49677532b3f" - integrity sha512-lzUVMuJ06HF4rYveaz9Tv0WRlUMxJ0Y1hgSkkgg+50iEdaI0TthyEDe08KIHb0XsF6rn8WYTqPCaGTZg3sX+qA== - dependencies: - loader-utils "^1.1.0" - schema-utils "^2.0.1" - raw-loader@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.1.tgz#14e1f726a359b68437e183d5a5b7d33a3eba6933" @@ -19049,12 +18000,17 @@ react-beautiful-dnd@^13.0.0: redux "^4.0.4" use-memo-one "^1.1.1" -react-clientside-effect@^1.2.2: - version "1.2.2" - resolved "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.2.tgz#6212fb0e07b204e714581dd51992603d1accc837" - integrity sha512-nRmoyxeok5PBO6ytPvSjKp9xwXg9xagoTK1mMjwnQxqM9Hd7MNPl+LS1bOSOe+CV2+4fnEquc7H/S8QD3q697A== +react-color@^2.17.0: + version "2.18.1" + resolved "https://registry.npmjs.org/react-color/-/react-color-2.18.1.tgz#2cda8cc8e06a9e2c52ad391a30ddad31972472f4" + integrity sha512-X5XpyJS6ncplZs74ak0JJoqPi+33Nzpv5RYWWxn17bslih+X7OlgmfpmGC1fNvdkK7/SGWYf1JJdn7D2n5gSuQ== dependencies: - "@babel/runtime" "^7.0.0" + "@icons/material" "^0.2.4" + lodash "^4.17.11" + material-colors "^1.2.1" + prop-types "^15.5.10" + reactcss "^1.2.0" + tinycolor2 "^1.4.1" react-copy-to-clipboard@5.0.1: version "5.0.1" @@ -19072,7 +18028,7 @@ react-debounce-input@^3.2.0: lodash.debounce "^4" prop-types "^15.7.2" -react-dev-utils@^10.2.1: +react-dev-utils@^10.0.0, react-dev-utils@^10.2.1: version "10.2.1" resolved "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-10.2.1.tgz#f6de325ae25fa4d546d09df4bb1befdc6dd19c19" integrity sha512-XxTbgJnYZmxuPtY3y/UV0D8/65NKkmaia4rXzViknVnZeVlklSh8u6TnaEYPfAi/Gh1TP4mEOXHI6jQOPbeakQ== @@ -19102,36 +18058,31 @@ react-dev-utils@^10.2.1: strip-ansi "6.0.0" text-table "0.2.0" -react-dev-utils@^9.0.0, react-dev-utils@^9.1.0: - version "9.1.0" - resolved "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-9.1.0.tgz#3ad2bb8848a32319d760d0a84c56c14bdaae5e81" - integrity sha512-X2KYF/lIGyGwP/F/oXgGDF24nxDA2KC4b7AFto+eqzc/t838gpSGiaU8trTqHXOohuLxxc5qi1eDzsl9ucPDpg== +react-docgen-typescript-loader@^3.7.2: + version "3.7.2" + resolved "https://registry.npmjs.org/react-docgen-typescript-loader/-/react-docgen-typescript-loader-3.7.2.tgz#45cb2305652c0602767242a8700ad1ebd66bbbbd" + integrity sha512-fNzUayyUGzSyoOl7E89VaPKJk9dpvdSgyXg81cUkwy0u+NBvkzQG3FC5WBIlXda0k/iaxS+PWi+OC+tUiGxzPA== dependencies: - "@babel/code-frame" "7.5.5" - address "1.1.2" - browserslist "4.7.0" - chalk "2.4.2" - cross-spawn "6.0.5" - detect-port-alt "1.1.6" - escape-string-regexp "1.0.5" - filesize "3.6.1" - find-up "3.0.0" - fork-ts-checker-webpack-plugin "1.5.0" - global-modules "2.0.0" - globby "8.0.2" - gzip-size "5.1.1" - immer "1.10.0" - inquirer "6.5.0" - is-root "2.1.0" - loader-utils "1.2.3" - open "^6.3.0" - pkg-up "2.0.0" - react-error-overlay "^6.0.3" - recursive-readdir "2.2.2" - shell-quote "1.7.2" - sockjs-client "1.4.0" - strip-ansi "5.2.0" - text-table "0.2.0" + "@webpack-contrib/schema-utils" "^1.0.0-beta.0" + loader-utils "^1.2.3" + react-docgen-typescript "^1.15.0" + +react-docgen-typescript-plugin@^0.5.2: + version "0.5.2" + resolved "https://registry.npmjs.org/react-docgen-typescript-plugin/-/react-docgen-typescript-plugin-0.5.2.tgz#2b294d75ef3145c36303da82be5d447cb67dc0dc" + integrity sha512-NQfWyWLmzUnedkiN2nPDb6Nkm68ik6fqbC3UvgjqYSeZsbKijXUA4bmV6aU7qICOXdop9PevPdjEgJuAN0nNVQ== + dependencies: + debug "^4.1.1" + endent "^2.0.1" + micromatch "^4.0.2" + react-docgen-typescript "^1.20.1" + react-docgen-typescript-loader "^3.7.2" + tslib "^2.0.0" + +react-docgen-typescript@^1.15.0, react-docgen-typescript@^1.20.1: + version "1.20.4" + resolved "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-1.20.4.tgz#9a5655986077ccfc58c1a447f92c3d19f7a875bf" + integrity sha512-gE2SeseJd6+o981qr9VQJRbvFJ5LjLSKQiwhHsuLN4flt+lheKtG1jp2BPzrv2MKR5gmbLwpmNtK4wbLCPSZAw== react-docgen@^5.0.0: version "5.3.0" @@ -19147,7 +18098,7 @@ react-docgen@^5.0.0: node-dir "^0.1.10" strip-indent "^3.0.0" -react-dom@^16.12.0, react-dom@^16.13.1, react-dom@^16.8.3, react-dom@^16.8.4: +react-dom@^16.12.0, react-dom@^16.13.1, react-dom@^16.8.3: version "16.13.1" resolved "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== @@ -19170,7 +18121,7 @@ react-draggable@^4.0.3: classnames "^2.2.5" prop-types "^15.6.0" -react-error-overlay@^6.0.3, react-error-overlay@^6.0.7: +react-error-overlay@^6.0.7: version "6.0.7" resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108" integrity sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA== @@ -19185,18 +18136,6 @@ react-fast-compare@^3.1.1: resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== -react-focus-lock@^2.1.0: - version "2.2.1" - resolved "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.2.1.tgz#1d12887416925dc53481914b7cedd39494a3b24a" - integrity sha512-47g0xYcCTZccdzKRGufepY8oZ3W1Qg+2hn6u9SHZ0zUB6uz/4K4xJe7yYFNZ1qT6m+2JDm82F6QgKeBTbjW4PQ== - dependencies: - "@babel/runtime" "^7.0.0" - focus-lock "^0.6.6" - prop-types "^15.6.2" - react-clientside-effect "^1.2.2" - use-callback-ref "^1.2.1" - use-sidecar "^1.0.1" - react-helmet-async@^1.0.2: version "1.0.4" resolved "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.0.4.tgz#079ef10b7fefcaee6240fefd150711e62463cc97" @@ -19218,10 +18157,10 @@ react-helmet@6.1.0: react-fast-compare "^3.1.1" react-side-effect "^2.1.0" -react-hook-form@^5.7.2: - version "5.7.2" - resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-5.7.2.tgz#a84e259e5d37dd30949af4f79c4dac31101b79ac" - integrity sha512-bJvY348vayIvEUmSK7Fvea/NgqbT2racA2IbnJz/aPlQ3GBtaTeDITH6rtCa6y++obZzG6E3Q8VuoXPir7QYUg== +react-hook-form@^6.6.0: + version "6.6.0" + resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-6.6.0.tgz#bda76fc50a916096afe18119f6f57f2b3911cdb7" + integrity sha512-5UKx2SBMaWuwy614H730Vv2QmoGdsdYpN96MnWXYzC8cHIUi9N2hYwtI86TEIcBHJ6FvEewh3onfA7P243RM1g== react-hot-loader@^4.12.21: version "4.12.21" @@ -19265,21 +18204,21 @@ react-inspector@^2.3.0: is-dom "^1.0.9" prop-types "^15.6.1" -react-inspector@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/react-inspector/-/react-inspector-4.0.1.tgz#0f888f78ff7daccbc7be5d452b20c96dc6d5fbb8" - integrity sha512-xSiM6CE79JBqSj8Fzd9dWBHv57tLTH7OM57GP3VrE5crzVF3D5Khce9w1Xcw75OAbvrA0Mi2vBneR1OajKmXFg== +react-inspector@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/react-inspector/-/react-inspector-5.0.1.tgz#8a30f3d488c4f40203624bbe24800f508ae05d3a" + integrity sha512-qRIENuAIcRaytrmg/TL5nN5igYZMzyQqIKlWA8zoYRDltULsZC1bWy2Ua5wYJuwEYnC3gK4FCjcIQnb+5OyLsQ== dependencies: - "@babel/runtime" "^7.6.3" - is-dom "^1.0.9" + "@babel/runtime" "^7.8.7" + is-dom "^1.1.0" prop-types "^15.6.1" -react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.6, react-is@^16.9.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-lazylog@^4.5.2: +react-lazylog@^4.5.2, react-lazylog@^4.5.3: version "4.5.3" resolved "https://registry.npmjs.org/react-lazylog/-/react-lazylog-4.5.3.tgz#289e24995b5599e75943556ac63f5e2c04d0001e" integrity sha512-lyov32A/4BqihgXgtNXTHCajXSXkYHPlIEmV8RbYjHIMxCFSnmtdg4kDCI3vATz7dURtiFTvrw5yonHnrS+NNg== @@ -19322,15 +18261,15 @@ react-motion@^0.5.2: prop-types "^15.5.8" raf "^3.1.0" -react-popper-tooltip@^2.8.3: - version "2.10.1" - resolved "https://registry.npmjs.org/react-popper-tooltip/-/react-popper-tooltip-2.10.1.tgz#e10875f31916297c694d64a677d6f8fa0a48b4d1" - integrity sha512-cib8bKiyYcrIlHo9zXx81G0XvARfL8Jt+xum709MFCgQa3HTqTi4au3iJ9tm7vi7WU7ngnqbpWkMinBOtwo+IQ== +react-popper-tooltip@^2.11.0: + version "2.11.1" + resolved "https://registry.npmjs.org/react-popper-tooltip/-/react-popper-tooltip-2.11.1.tgz#3c4bdfd8bc10d1c2b9a162e859bab8958f5b2644" + integrity sha512-04A2f24GhyyMicKvg/koIOQ5BzlrRbKiAgP6L+Pdj1MVX3yJ1NeZ8+EidndQsbejFT55oW1b++wg2Z8KlAyhfQ== dependencies: - "@babel/runtime" "^7.7.4" - react-popper "^1.3.6" + "@babel/runtime" "^7.9.2" + react-popper "^1.3.7" -react-popper@^1.3.6: +react-popper@^1.3.7: version "1.3.7" resolved "https://registry.npmjs.org/react-popper/-/react-popper-1.3.7.tgz#f6a3471362ef1f0d10a4963673789de1baca2324" integrity sha512-nmqYTx7QVjCm3WUZLeuOomna138R1luC4EqkW3hxJUrAe+3eNz3oFCLYdnPwILfn0mX1Ew2c3wctrjlUMYYUww== @@ -19375,6 +18314,35 @@ react-router-dom@6.0.0-beta.0: prop-types "^15.7.2" react-router "6.0.0-beta.0" +react-router-dom@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" + integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + loose-envify "^1.3.1" + prop-types "^15.6.2" + react-router "5.2.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-router@5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" + integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + hoist-non-react-statics "^3.1.0" + loose-envify "^1.3.1" + mini-create-react-context "^0.4.0" + path-to-regexp "^1.7.0" + prop-types "^15.6.2" + react-is "^16.6.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + react-router@6.0.0-beta.0: version "6.0.0-beta.0" resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-beta.0.tgz#3e11f39b6ded4412c2fed9e4f989dd4c8156724d" @@ -19411,7 +18379,7 @@ react-string-replace@^0.4.1: dependencies: lodash "^4.17.4" -react-syntax-highlighter@=12.2.1: +react-syntax-highlighter@=12.2.1, react-syntax-highlighter@^12.2.1: version "12.2.1" resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-12.2.1.tgz#14d78352da1c1c3f93c6698b70ec7c706b83493e" integrity sha512-CTsp0ZWijwKRYFg9xhkWD4DSpQqE4vb2NKVMdPAkomnILSmsNBHE0n5GuI5zB+PU3ySVvXvdt9jo+ViD9XibCA== @@ -19422,27 +18390,16 @@ react-syntax-highlighter@=12.2.1: prismjs "^1.8.4" refractor "^2.4.1" -react-syntax-highlighter@^11.0.2: - version "11.0.2" - resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-11.0.2.tgz#4e3f376e752b20d2f54e4c55652fd663149e4029" - integrity sha512-kqmpM2OH5OodInbEADKARwccwSQWBfZi0970l5Jhp4h39q9Q65C4frNcnd6uHE5pR00W8pOWj9HDRntj2G4Rww== - dependencies: - "@babel/runtime" "^7.3.1" - highlight.js "~9.13.0" - lowlight "~1.11.0" - prismjs "^1.8.4" - refractor "^2.4.1" - -react-syntax-highlighter@^13.2.1: - version "13.2.1" - resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-13.2.1.tgz#3d5a3b655cd85ce06b9508e0365d01ef9b8560bb" - integrity sha512-O/AF/ll4I3Dp+2WuKbnF5yKG4b1BUncqcpTW6MIBDJPr2eGKqlNGS3Nv+lyCFtAIHx8N+/ktti8qH14Q5VjjcA== +react-syntax-highlighter@^13.5.1: + version "13.5.1" + resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-13.5.1.tgz#f21737cf6d582474a0f18b06b52613f4349c0e64" + integrity sha512-VVYTnFXF55WMRGdr3QNEzAzcypFZqH45kS7rqh90+AFeNGtui8/gV5AIOIJjwTsuP2UxcO9qvEq94Jq9BYFUhw== dependencies: "@babel/runtime" "^7.3.1" highlight.js "^10.1.1" lowlight "^1.14.0" - prismjs "^1.20.0" - refractor "^3.0.0" + prismjs "^1.21.0" + refractor "^3.1.0" react-test-renderer@^16.13.1: version "16.13.1" @@ -19454,18 +18411,19 @@ react-test-renderer@^16.13.1: react-is "^16.8.6" scheduler "^0.19.1" -react-textarea-autosize@^7.1.0: - version "7.1.2" - resolved "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-7.1.2.tgz#70fdb333ef86bcca72717e25e623e90c336e2cda" - integrity sha512-uH3ORCsCa3C6LHxExExhF4jHoXYCQwE5oECmrRsunlspaDAbS4mGKNlWZqjLfInWtFQcf0o1n1jC/NGXFdUBCg== +react-textarea-autosize@^8.1.1: + version "8.2.0" + resolved "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.2.0.tgz#fae38653f5ec172a855fd5fffb39e466d56aebdb" + integrity sha512-grajUlVbkx6VdtSxCgzloUIphIZF5bKr21OYMceWPKkniy7H0mRAT/AXPrRtObAe+zUePnNlBwUc4ivVjUGIjw== dependencies: - "@babel/runtime" "^7.1.2" - prop-types "^15.6.0" + "@babel/runtime" "^7.10.2" + use-composed-ref "^1.0.0" + use-latest "^1.0.0" -react-transition-group@^4.0.0, react-transition-group@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.3.0.tgz#fea832e386cf8796c58b61874a3319704f5ce683" - integrity sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw== +react-transition-group@^4.0.0, react-transition-group@^4.4.0: + version "4.4.1" + resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" + integrity sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw== dependencies: "@babel/runtime" "^7.5.5" dom-helpers "^5.0.1" @@ -19495,9 +18453,9 @@ react-use@^12.2.0: tslib "^1.10.0" react-use@^15.3.3: - version "15.3.3" - resolved "https://registry.npmjs.org/react-use/-/react-use-15.3.3.tgz#f16de7a16286c446388e8bd99680952fc3dc9a95" - integrity sha512-nYb94JbmDCaLZg3sOXmFW8HN+lXWxnl0caspXoYfZG1CON8JfLN9jMOyxRDUpm7dUq7WZ5mIept/ByqBQKJ0wQ== + version "15.3.4" + resolved "https://registry.npmjs.org/react-use/-/react-use-15.3.4.tgz#f853d310bd71f75b38900a8caa3db93f6dc6e872" + integrity sha512-cHq1dELW6122oi1+xX7lwNyE/ugZs5L902BuO8eFJCfn2api1KeuPVG1M/GJouVARoUf54S2dYFMKo5nQXdTag== dependencies: "@types/js-cookie" "2.2.6" "@xobotyi/scrollbar-width" "1.9.5" @@ -19531,7 +18489,7 @@ react-wait@^0.3.0: resolved "https://registry.npmjs.org/react-wait/-/react-wait-0.3.0.tgz#0cdd4d919012451a5bc3ab0a16d00c6fd9a8c10b" integrity sha512-kB5x/kMKWcn0uVr9gBdNz21/oGbQwEQnF3P9p6E9yLfJ9DRcKS0fagbgYMFI0YFOoyKDj+2q6Rwax0kTYJF37g== -react@^16.0.0, react@^16.12.0, react@^16.13.1, react@^16.8.3, react@^16.8.4: +react@^16.0.0, react@^16.12.0, react@^16.13.1, react@^16.8.3, react@^16.9.17: version "16.13.1" resolved "https://registry.npmjs.org/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== @@ -19540,6 +18498,13 @@ react@^16.0.0, react@^16.12.0, react@^16.13.1, react@^16.8.3, react@^16.8.4: object-assign "^4.1.1" prop-types "^15.6.2" +reactcss@^1.2.0: + version "1.2.3" + resolved "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd" + integrity sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A== + dependencies: + lodash "^4.0.1" + read-cmd-shim@^1.0.1: version "1.0.5" resolved "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-1.0.5.tgz#87e43eba50098ba5a32d0ceb583ab8e43b961c16" @@ -19779,14 +18744,14 @@ refractor@^2.4.1: parse-entities "^1.1.2" prismjs "~1.17.0" -refractor@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/refractor/-/refractor-3.0.0.tgz#7c8072eaf49dbc1b333e7acc64fb52a1c9b17c75" - integrity sha512-eCGK/oP4VuyW/ERqjMZRZHxl2QsztbkedkYy/SxqE/+Gh1gLaAF17tWIOcVJDiyGhar1NZy/0B9dFef7J0+FDw== +refractor@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/refractor/-/refractor-3.1.0.tgz#b05a43c8a1b4fccb30001ffcbd5cd781f7f06f78" + integrity sha512-bN8GvY6hpeXfC4SzWmYNQGLLF2ZakRDNBkgCL0vvl5hnpMrnyURk8Mv61v6pzn4/RBHzSWLp44SzMmVHqMGNww== dependencies: hastscript "^5.0.0" parse-entities "^2.0.0" - prismjs "~1.20.0" + prismjs "~1.21.0" regenerate-unicode-properties@^8.2.0: version "8.2.0" @@ -19805,7 +18770,7 @@ regenerator-runtime@^0.11.0: resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== -regenerator-runtime@^0.13.2, regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.5: +regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.5: version "0.13.5" resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697" integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA== @@ -19933,15 +18898,7 @@ remark-parse@^5.0.0: vfile-location "^2.0.0" xtend "^4.0.1" -remarkable@^1.7.1: - version "1.7.4" - resolved "https://registry.npmjs.org/remarkable/-/remarkable-1.7.4.tgz#19073cb960398c87a7d6546eaa5e50d2022fcd00" - integrity sha512-e6NKUXgX95whv7IgddywbeN/ItCkWbISmc2DiqHJb0wTrqZIexqdco5b8Z3XZoo/48IdNVKM9ZCvTPJ4F5uvhg== - dependencies: - argparse "^1.0.10" - autolinker "~0.28.0" - -remarkable@^2.0.0, remarkable@^2.0.1: +remarkable@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/remarkable/-/remarkable-2.0.1.tgz#280ae6627384dfb13d98ee3995627ca550a12f31" integrity sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA== @@ -19997,11 +18954,6 @@ replace-ext@1.0.0: resolved "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs= -replace-ext@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a" - integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw== - replace-in-file@^6.0.0: version "6.1.0" resolved "https://registry.npmjs.org/replace-in-file/-/replace-in-file-6.1.0.tgz#9f9ddd7bb70d6ad231d2ad692e1b646e73d06647" @@ -20016,7 +18968,7 @@ replaceall@^0.1.6: resolved "https://registry.npmjs.org/replaceall/-/replaceall-0.1.6.tgz#81d81ac7aeb72d7f5c4942adf2697a3220688d8e" integrity sha1-gdgax663LX9cSUKt8ml6MiBojY4= -request-progress@3.0.0: +request-progress@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe" integrity sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4= @@ -20039,7 +18991,7 @@ request-promise-native@^1.0.5, request-promise-native@^1.0.8: stealthy-require "^1.1.1" tough-cookie "^2.3.3" -request@2.88.2, request@^2.53.0, request@^2.55.0, request@^2.85.0, request@^2.87.0, request@^2.88.0, request@^2.88.2: +request@2.88.2, request@^2.55.0, request@^2.85.0, request@^2.87.0, request@^2.88.0, request@^2.88.2: version "2.88.2" resolved "https://registry.npmjs.org/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== @@ -20095,6 +19047,11 @@ resize-observer-polyfill@^1.5.1: resolved "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== +resolve-alpn@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.0.0.tgz#745ad60b3d6aff4b4a48e01b8c0bdc70959e0e8c" + integrity sha512-rTuiIEqFmGxne4IovivKSDzld2lWW9QCjqv80SYjPgf+gS35eaCAjaP54CCwGAwBtnCsvNLYtqxe1Nw+i6JEmA== + resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" @@ -20132,6 +19089,11 @@ resolve-from@^4.0.0: resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +resolve-pathname@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" + integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== + resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" @@ -20151,13 +19113,20 @@ resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.11.0, resolve@^1.12. dependencies: path-parse "^1.0.6" -responselike@1.0.2, responselike@^1.0.2: +responselike@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec= dependencies: lowercase-keys "^1.0.0" +responselike@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz#26391bcc3174f750f9a79eacc40a12a5c42d7723" + integrity sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw== + dependencies: + lowercase-keys "^2.0.0" + restore-cursor@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" @@ -20224,7 +19193,7 @@ rifm@^0.7.0: dependencies: "@babel/runtime" "^7.3.1" -rimraf@2, rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@^2.7.1: +rimraf@2, rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -20238,7 +19207,7 @@ rimraf@2.6.3: dependencies: glob "^7.1.3" -rimraf@^3.0.0: +rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -20253,10 +19222,10 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" -rollup-plugin-dts@^1.4.6: - version "1.4.10" - resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.10.tgz#0373c4284a2ba4d2d72df69c289271a816bc2736" - integrity sha512-bL6MBXc8lK7D5b/tYbHaglxs4ZxMQTQilGA6Xm9KQBEj4h9ZwIDlAsvDooGjJ/cOw23r3POTRtSCEyTHxtzHJg== +rollup-plugin-dts@1.4.11: + version "1.4.11" + resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.11.tgz#aedf0b7bb91d51e20b755e2c18e840edfc7af7a1" + integrity sha512-yiScAMKgwH77b44a/IFGgjLsmwSlNfQhEM+eCb2uMrupQMPE1n/12wrnT431+v1u6wYMF1XuHqldh+v/7mTvYA== optionalDependencies: "@babel/code-frame" "^7.10.4" @@ -20281,9 +19250,9 @@ rollup-plugin-peer-deps-external@^2.2.2: integrity sha512-W6IePXTExGXVDAlfZbNUUrx3GxUOZP248u5n4a4ID1XZMrbQ+uGeNiEfapvdzwx0qZi5DNH/hDLiPUP+pzFIxg== rollup-plugin-postcss@^3.1.1: - version "3.1.1" - resolved "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-3.1.1.tgz#eb895bd919285aaf6200324071103b4d3ea68607" - integrity sha512-4/FO5/2O5kv2uWRd7PPTN4mBCWoHwwFTnpEGokfPKfj6kygvTORqkBWNgVPXi7bBefNKtMA3FqQ10se6/J8kKw== + version "3.1.8" + resolved "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-3.1.8.tgz#d1bcaf8eb0fcb0936e3684c22dd8628d13a82fd1" + integrity sha512-JHnGfW8quNc6ePxEkZ05HEZ1YiRxDgY9RKEetMfsrwxR2kh/d90OVScTc6b1c2Q17Cs/5TRYL+1uddG21lQe3w== dependencies: chalk "^4.0.0" concat-with-sourcemaps "^1.1.0" @@ -20380,14 +19349,7 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.5.2, rxjs@^6.5.3, rxjs@^6.5.5: - version "6.6.0" - resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.0.tgz#af2901eedf02e3a83ffa7f886240ff9018bbec84" - integrity sha512-3HMA8z/Oz61DUHe+SdOiQyzIf4tOx5oQHmMir7IZEu6TMqCLHT4LRcmNaUS0NwOz8VLvmmBduMsoaUvMaIiqzg== - dependencies: - tslib "^1.9.0" - -rxjs@^6.6.0: +rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.5.2, rxjs@^6.5.3, rxjs@^6.5.5, rxjs@^6.6.0: version "6.6.2" resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2" integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg== @@ -20414,11 +19376,6 @@ safe-identifier@^0.4.1: resolved "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.1.tgz#b6516bf72594f03142b5f914f4c01842ccb1b678" integrity sha512-73tOz5TXsq3apuCc3vC8c9QRhhdNZGiBhHmPPjqpH4TO5oCDqk8UIsDcSs/RG6dYcFAkOOva0pqHS3u7hh7XXA== -safe-json-parse@~1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-1.0.1.tgz#3e76723e38dfdda13c9b1d29a1e07ffee4b30b57" - integrity sha1-PnZyPjjf3aE8mx0poeB//uSzC1c= - safe-regex@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" @@ -20487,14 +19444,14 @@ schema-utils@^1.0.0: ajv-errors "^1.0.0" ajv-keywords "^3.1.0" -schema-utils@^2.0.1, schema-utils@^2.5.0, schema-utils@^2.6.4, schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0: - version "2.7.0" - resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" - integrity sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A== +schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0, schema-utils@^2.7.1: + version "2.7.1" + resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" + integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg== dependencies: - "@types/json-schema" "^7.0.4" - ajv "^6.12.2" - ajv-keywords "^3.4.1" + "@types/json-schema" "^7.0.5" + ajv "^6.12.4" + ajv-keywords "^3.5.2" screenfull@^5.0.0: version "5.0.2" @@ -20506,13 +19463,6 @@ scuid@^1.0.2: resolved "https://registry.npmjs.org/scuid/-/scuid-1.1.0.tgz#d3f9f920956e737a60f72d0e4ad280bf324d5dab" integrity sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg== -seek-bzip@^1.0.5: - version "1.0.6" - resolved "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz#35c4171f55a680916b52a07859ecf3b5857f21c4" - integrity sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ== - dependencies: - commander "^2.8.1" - select-hose@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -20547,13 +19497,6 @@ semver-regex@^2.0.0: resolved "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz#a93c2c5844539a770233379107b38c7b4ac9d338" integrity sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw== -semver-truncate@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/semver-truncate/-/semver-truncate-1.1.2.tgz#57f41de69707a62709a7e0104ba2117109ea47e8" - integrity sha1-V/Qd5pcHpicJp+AQS6IRcQnqR+g= - dependencies: - semver "^5.3.0" - "semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: version "5.7.1" resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" @@ -20613,6 +19556,13 @@ serialize-javascript@^2.1.2: resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== +serialize-javascript@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" + integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== + dependencies: + randombytes "^2.1.0" + serve-favicon@^2.5.0: version "2.5.0" resolved "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.0.tgz#935d240cdfe0f5805307fdfe967d88942a2cbcf0" @@ -20666,13 +19616,6 @@ set-blocking@^2.0.0, set-blocking@~2.0.0: resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= -set-getter@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/set-getter/-/set-getter-0.1.0.tgz#d769c182c9d5a51f409145f2fba82e5e86e80376" - integrity sha1-12nBgsnVpR9AkUXy+6guXoboA3Y= - dependencies: - to-object-path "^0.3.0" - set-harmonic-interval@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz#e1773705539cdfb80ce1c3d99e7f298bb3995249" @@ -20733,11 +19676,6 @@ shallow-clone@^3.0.0: dependencies: kind-of "^6.0.2" -shallow-equal@^1.1.0: - version "1.2.1" - resolved "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz#4c16abfa56043aa20d050324efa68940b0da79da" - integrity sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA== - shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" @@ -20773,15 +19711,6 @@ shell-quote@1.7.2: integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== shelljs@^0.8.3: - version "0.8.3" - resolved "https://registry.npmjs.org/shelljs/-/shelljs-0.8.3.tgz#a7f3319520ebf09ee81275b2368adb286659b097" - integrity sha512-fc0BKlAWiLpwZljmOvAOTE/gXawtCoNrP5oaY7KIaQbbyHeQVg01pSEuEGvGh3HEdBU4baCD7wQBwADmM/7f7A== - dependencies: - glob "^7.0.0" - interpret "^1.0.0" - rechoir "^0.6.2" - -shelljs@^0.8.4: version "0.8.4" resolved "https://registry.npmjs.org/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2" integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ== @@ -20820,6 +19749,20 @@ signedsource@^1.0.0: resolved "https://registry.npmjs.org/signedsource/-/signedsource-1.0.0.tgz#1ddace4981798f93bd833973803d80d52e93ad6a" integrity sha1-HdrOSYF5j5O9gzlzgD2A1S6TrWo= +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^3.0.3: + version "3.1.0" + resolved "https://registry.npmjs.org/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" + integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== + dependencies: + decompress-response "^4.2.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-swizzle@^0.2.2: version "0.2.2" resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" @@ -20827,41 +19770,11 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" -simplebar-react@^1.0.0-alpha.6: - version "1.2.3" - resolved "https://registry.npmjs.org/simplebar-react/-/simplebar-react-1.2.3.tgz#bd81fa9827628470e9470d06caef6ece15e1c882" - integrity sha512-1EOWJzFC7eqHUp1igD1/tb8GBv5aPQA5ZMvpeDnVkpNJ3jAuvmrL2kir3HuijlxhG7njvw9ssxjjBa89E5DrJg== - dependencies: - prop-types "^15.6.1" - simplebar "^4.2.3" - -simplebar@^4.2.3: - version "4.2.3" - resolved "https://registry.npmjs.org/simplebar/-/simplebar-4.2.3.tgz#dac40aced299c17928329eab3d5e6e795fafc10c" - integrity sha512-9no0pK7/1y+8/oTF3sy/+kx0PjQ3uk4cYwld5F1CJGk2gx+prRyUq8GRfvcVLq5niYWSozZdX73a2wIr1o9l/g== - dependencies: - can-use-dom "^0.1.0" - core-js "^3.0.1" - lodash.debounce "^4.0.8" - lodash.memoize "^4.1.2" - lodash.throttle "^4.1.1" - resize-observer-polyfill "^1.5.1" - sisteransi@^1.0.4: version "1.0.5" resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== -sitemap@^3.2.2: - version "3.2.2" - resolved "https://registry.npmjs.org/sitemap/-/sitemap-3.2.2.tgz#3f77c358fa97b555c879e457098e39910095c62b" - integrity sha512-TModL/WU4m2q/mQcrDgNANn0P4LwprM9MMvG4hu5zP4c6IIKs2YLTu6nXXnNr8ODW/WFtxKggiJ1EGn2W0GNmg== - dependencies: - lodash.chunk "^4.2.0" - lodash.padstart "^4.6.1" - whatwg-url "^7.0.0" - xmlbuilder "^13.0.0" - slash@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" @@ -20986,13 +19899,6 @@ socks@~2.3.2: ip "1.1.5" smart-buffer "^4.1.0" -sort-keys-length@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz#9cb6f4f4e9e48155a6aa0671edd336ff1479a188" - integrity sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg= - dependencies: - sort-keys "^1.0.0" - sort-keys@^1.0.0: version "1.1.2" resolved "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" @@ -21125,6 +20031,11 @@ split-ca@^1.0.1: resolved "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz#6c83aff3692fa61256e0cd197e05e9de157691a6" integrity sha1-bIOv82kvphJW4M0ZfgXp3hV2kaY= +split-on-first@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" + integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw== + split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" @@ -21166,15 +20077,6 @@ sqlite3@^4.2.0: nan "^2.12.1" node-pre-gyp "^0.11.0" -squeak@^1.0.0: - version "1.3.0" - resolved "https://registry.npmjs.org/squeak/-/squeak-1.3.0.tgz#33045037b64388b567674b84322a6521073916c3" - integrity sha1-MwRQN7ZDiLVnZ0uEMiplIQc5FsM= - dependencies: - chalk "^1.0.0" - console-stream "^0.1.1" - lpad-align "^1.0.1" - srcset@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/srcset/-/srcset-2.0.1.tgz#8f842d357487eb797f413d9c309de7a5149df5ac" @@ -21218,12 +20120,11 @@ ssri@^6.0.0, ssri@^6.0.1: dependencies: figgy-pudding "^3.5.1" -ssri@^7.0.0: - version "7.1.0" - resolved "https://registry.npmjs.org/ssri/-/ssri-7.1.0.tgz#92c241bf6de82365b5c7fb4bd76e975522e1294d" - integrity sha512-77/WrDZUWocK0mvA5NTRQyveUf+wsrIc6vyrxpS8tVvYBcX215QbafrJR3KtkpskIzoFLqqNuuYQvxaMjXJ/0g== +ssri@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/ssri/-/ssri-8.0.0.tgz#79ca74e21f8ceaeddfcb4b90143c458b8d988808" + integrity sha512-aq/pz989nxVYwn16Tsbj1TqFpD5LLrQxHf5zaHuieFV+R0Bbr4y8qUsOA45hXT/N4/9UNXTarBjnjVmjSOVaAA== dependencies: - figgy-pudding "^3.5.1" minipass "^3.1.1" stable@^0.1.8: @@ -21338,10 +20239,10 @@ store2@^2.7.1: resolved "https://registry.npmjs.org/store2/-/store2-2.10.0.tgz#46b82bb91878daf1b0d56dec2f1d41e54d5103cf" integrity sha512-tWEpK0snS2RPUq1i3R6OahfJNjWCQYNxq0+by1amCSuw0mXtymJpzmZIeYpA1UAa+7B0grCpNYIbDcd7AgTbFg== -storybook-dark-mode@^0.6.1: - version "0.6.1" - resolved "https://registry.npmjs.org/storybook-dark-mode/-/storybook-dark-mode-0.6.1.tgz#0527567ac5853c49f6f7a6f68f792fe9322c5a5a" - integrity sha512-E8LIHnVfFhOsPqBc2fLshVBnspziYMXHdwQc/qAjpf4h5ewzrDzeqy4QfJioE+jDoyyZXEtIMugzb0wIaK10Uw== +storybook-dark-mode@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/storybook-dark-mode/-/storybook-dark-mode-1.0.2.tgz#963007e72b628e0efe29bdc5ee8449513bc056b8" + integrity sha512-HBeXTUzYaRgdGJ6YbmcJ3RXw47xp2b5kegZwtcqnSvRblNUZlAKSKsD6UQTWA30hPJ+u3O/5OE5bXk8+H4Zdqg== dependencies: fast-deep-equal "^3.0.0" memoizerific "^1.11.3" @@ -21402,6 +20303,11 @@ strict-uri-encode@^1.0.0: resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= +strict-uri-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" + integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= + string-argv@0.3.1: version "0.3.1" resolved "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" @@ -21425,11 +20331,6 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -string-template@~0.2.1: - version "0.2.1" - resolved "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" - integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0= - string-width@^1.0.1, string-width@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" @@ -21509,11 +20410,6 @@ string.prototype.trimright@^2.1.1: define-properties "^1.1.3" function-bind "^1.1.1" -string_decoder@0.10: - version "0.10.31" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= - string_decoder@^1.0.0, string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" @@ -21582,18 +20478,6 @@ strip-bom@^4.0.0: resolved "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== -strip-color@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/strip-color/-/strip-color-0.1.0.tgz#106f65d3d3e6a2d9401cac0eb0ce8b8a702b4f7b" - integrity sha1-EG9l09PmotlAHKwOsM6LinArT3s= - -strip-dirs@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz#4987736264fc344cf20f6c34aca9d13d1d4ed6c5" - integrity sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g== - dependencies: - is-natural-number "^4.0.1" - strip-eof@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" @@ -21633,13 +20517,6 @@ strip-json-comments@~2.0.1: resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= -strip-outer@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz#b2fd2abf6604b9d1e6013057195df836b8a9d631" - integrity sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg== - dependencies: - escape-string-regexp "^1.0.2" - strong-log-transformer@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz#0f5ed78d325e0421ac6f90f7f10e691d6ae3ae10" @@ -21654,14 +20531,6 @@ style-inject@^0.3.0: resolved "https://registry.npmjs.org/style-inject/-/style-inject-0.3.0.tgz#d21c477affec91811cc82355832a700d22bf8dd3" integrity sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw== -style-loader@^1.0.0: - version "1.1.3" - resolved "https://registry.npmjs.org/style-loader/-/style-loader-1.1.3.tgz#9e826e69c683c4d9bf9db924f85e9abb30d5e200" - integrity sha512-rlkH7X/22yuwFYK357fMN/BxYOorfnfq0eD7+vqlemSK4wEcejFF1dg4zxP0euBW8NrYx2WZzZ8PPFevr7D+Kw== - dependencies: - loader-utils "^1.2.3" - schema-utils "^2.6.4" - style-loader@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/style-loader/-/style-loader-1.2.1.tgz#c5cbbfbf1170d076cfdd86e0109c5bba114baa1a" @@ -21750,13 +20619,6 @@ supertest@^4.0.2: methods "^1.1.2" superagent "^3.8.3" -supports-color@7.1.0, supports-color@^7.0.0, supports-color@^7.1.0: - version "7.1.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" - integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== - dependencies: - has-flag "^4.0.0" - supports-color@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" @@ -21783,6 +20645,13 @@ supports-color@^6.1.0: dependencies: has-flag "^3.0.0" +supports-color@^7.0.0, supports-color@^7.1.0: + version "7.1.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== + dependencies: + has-flag "^4.0.0" + supports-hyperlinks@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.1.0.tgz#f663df252af5f37c5d49bbd7eeefa9e0b9e59e47" @@ -21791,12 +20660,12 @@ supports-hyperlinks@^2.0.0: has-flag "^4.0.0" supports-color "^7.0.0" -svg-parser@^2.0.0, svg-parser@^2.0.2: +svg-parser@^2.0.2: version "2.0.4" resolved "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== -svgo@^1.0.0, svgo@^1.2.2, svgo@^1.3.2: +svgo@^1.0.0, svgo@^1.2.2: version "1.3.2" resolved "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167" integrity sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw== @@ -21877,10 +20746,10 @@ swagger-ui-react@^3.31.1: xml-but-prettier "^1.0.1" zenscroll "^4.0.2" -swr@^0.2.2: - version "0.2.3" - resolved "https://registry.npmjs.org/swr/-/swr-0.2.3.tgz#e0fb260d27f12fafa2388312083368f45127480d" - integrity sha512-JhuuD5ojqgjAQpZAhoPBd8Di0Mr1+ykByVKuRJdtKaxkUX/y8kMACWKkLgLQc8pcDOKEAnbIreNjU7HfqI9nHQ== +swr@^0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/swr/-/swr-0.3.0.tgz#69602953dd50ab07fe60c920fd63199256645675" + integrity sha512-3p0p5TWH0qiaKAph5wBkMwqe2WjNseITfjmdVoNzjqRZGn/gnpRi6whMDjhMVb/vp/yyDtKWPlyjid8QZH+UhA== dependencies: fast-deep-equal "2.0.1" @@ -21902,10 +20771,10 @@ symbol.prototype.description@^1.0.0: es-abstract "^1.17.0-next.1" has-symbols "^1.0.1" -synchronous-promise@^2.0.10: - version "2.0.11" - resolved "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.11.tgz#e92022b0754e916f556d3ace1626d24a24214b7e" - integrity sha512-8/L5FOCjnlK0FoAfj+NqdCaImMKvEyOEzGmdfcezKp5K9HIukm4akx72endvM87eS/gU8kOxiMQflpC/CdkQAg== +synchronous-promise@^2.0.13: + version "2.0.13" + resolved "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.13.tgz#9d8c165ddee69c5a6542862b405bc50095926702" + integrity sha512-R9N6uDkVsghHePKh1TEqbnLddO2IY25OcsksyFp/qBe7XYd0PVbKEWxhcdMhpLzE1I6skj5l4aEZ3CRxcbArlA== table@^5.2.3: version "5.4.6" @@ -21942,7 +20811,7 @@ tar-fs@~2.0.1: pump "^3.0.0" tar-stream "^2.0.0" -tar-stream@^1.1.2, tar-stream@^1.5.2: +tar-stream@^1.1.2: version "1.6.2" resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== @@ -21979,15 +20848,15 @@ tar@^4, tar@^4.4.10, tar@^4.4.12, tar@^4.4.8: safe-buffer "^5.1.2" yallist "^3.0.3" -tar@^6.0.1: - version "6.0.2" - resolved "https://registry.npmjs.org/tar/-/tar-6.0.2.tgz#5df17813468a6264ff14f766886c622b84ae2f39" - integrity sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg== +tar@^6.0.1, tar@^6.0.2: + version "6.0.5" + resolved "https://registry.npmjs.org/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f" + integrity sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg== dependencies: chownr "^2.0.0" fs-minipass "^2.0.0" minipass "^3.0.0" - minizlib "^2.1.0" + minizlib "^2.1.1" mkdirp "^1.0.3" yallist "^4.0.0" @@ -21996,26 +20865,25 @@ tarn@^3.0.0: resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.0.tgz#a4082405216c0cce182b8b4cb2639c52c1e870d4" integrity sha512-PKUnlDFODZueoA8owLehl8vLcgtA8u4dRuVbZc92tspDYZixjJL6TqYOmryf/PfP/EBX+2rgNcrj96NO+RPkdQ== -tcp-port-used@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.1.tgz#46061078e2d38c73979a2c2c12b5a674e6689d70" - integrity sha512-rwi5xJeU6utXoEIiMvVBMc9eJ2/ofzB+7nLOdnZuFTmNCLqRiQh2sMG9MqCxHU/69VC/Fwp5dV9306Qd54ll1Q== +tdigest@^0.1.1: + version "0.1.1" + resolved "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021" + integrity sha1-Ljyyw56kSeVdHmzZEReszKRYgCE= dependencies: - debug "4.1.0" - is2 "2.0.1" + bintrees "1.0.1" -telejson@^3.2.0: - version "3.3.0" - resolved "https://registry.npmjs.org/telejson/-/telejson-3.3.0.tgz#6d814f3c0d254d5c4770085aad063e266b56ad03" - integrity sha512-er08AylQ+LEbDLp1GRezORZu5wKOHaBczF6oYJtgC3Idv10qZ8A3p6ffT+J5BzDKkV9MqBvu8HAKiIIOp6KJ2w== +telejson@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/telejson/-/telejson-5.0.2.tgz#ed1e64be250cc1c757a53c19e1740b49832b3d51" + integrity sha512-XCrDHGbinczsscs8LXFr9jDhvy37yBk9piB7FJrCfxE8oP66WDkolNMpaBkWYgQqB9dQGBGtTDzGQPedc9KJmw== dependencies: "@types/is-function" "^1.0.0" global "^4.4.0" - is-function "^1.0.1" - is-regex "^1.0.4" + is-function "^1.0.2" + is-regex "^1.1.1" is-symbol "^1.0.3" isobject "^4.0.0" - lodash "^4.17.15" + lodash "^4.17.19" memoizerific "^1.11.3" temp-dir@^1.0.0: @@ -22035,14 +20903,6 @@ temp-write@^3.4.0: temp-dir "^1.0.0" uuid "^3.0.1" -tempfile@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/tempfile/-/tempfile-2.0.0.tgz#6b0446856a9b1114d1856ffcbe509cccb0977265" - integrity sha1-awRGhWqbERTRhW/8vlCczLCXcmU= - dependencies: - temp-dir "^1.0.0" - uuid "^3.0.1" - term-size@^2.1.0: version "2.2.0" resolved "https://registry.npmjs.org/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753" @@ -22071,25 +20931,25 @@ terser-webpack-plugin@^1.4.3: webpack-sources "^1.4.0" worker-farm "^1.7.0" -terser-webpack-plugin@^2.1.2: - version "2.3.5" - resolved "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-2.3.5.tgz#5ad971acce5c517440ba873ea4f09687de2f4a81" - integrity sha512-WlWksUoq+E4+JlJ+h+U+QUzXpcsMSSNXkDy9lBVkSqDn1w23Gg29L/ary9GeJVYCGiNJJX7LnVc4bwL1N3/g1w== +terser-webpack-plugin@^3.0.0: + version "3.1.0" + resolved "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-3.1.0.tgz#91e6d39571460ed240c0cf69d295bcf30ebf98cb" + integrity sha512-cjdZte66fYkZ65rQ2oJfrdCAkkhJA7YLYk5eGOcGCSGlq0ieZupRdjedSQXYknMPo2IveQL+tPdrxUkERENCFA== dependencies: - cacache "^13.0.1" - find-cache-dir "^3.2.0" - jest-worker "^25.1.0" - p-limit "^2.2.2" - schema-utils "^2.6.4" - serialize-javascript "^2.1.2" + cacache "^15.0.5" + find-cache-dir "^3.3.1" + jest-worker "^26.2.1" + p-limit "^3.0.2" + schema-utils "^2.6.6" + serialize-javascript "^4.0.0" source-map "^0.6.1" - terser "^4.4.3" + terser "^4.8.0" webpack-sources "^1.4.3" -terser@^4.1.2, terser@^4.4.3, terser@^4.6.3: - version "4.6.7" - resolved "https://registry.npmjs.org/terser/-/terser-4.6.7.tgz#478d7f9394ec1907f0e488c5f6a6a9a2bad55e72" - integrity sha512-fmr7M1f7DBly5cX2+rFDvmGBAaaZyPrHYK4mMdHEDAdNTqXSZgSOfqsfGq2HqPGT/1V0foZZuCZFx8CHKgAk3g== +terser@^4.1.2, terser@^4.6.3, terser@^4.8.0: + version "4.8.0" + resolved "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17" + integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw== dependencies: commander "^2.20.0" source-map "~0.6.1" @@ -22155,16 +21015,11 @@ throat@^5.0.0: resolved "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== -throttle-debounce@^2.0.1: +throttle-debounce@^2.0.1, throttle-debounce@^2.1.0: version "2.2.1" resolved "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.2.1.tgz#fbd933ae6793448816f7d5b3cae259d464c98137" integrity sha512-i9hAVld1f+woAiyNGqWelpDD5W1tpMroL3NofTz9xzwq6acWBlO2dC8k5EFSZepU6oOINtV5Q3aSPoRg7o4+fA== -throttle-debounce@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.1.0.tgz#257e648f0a56bd9e54fe0f132c4ab8611df4e1d5" - integrity sha512-AOvyNahXQuU7NN+VVvOOX+uW6FPaWdAOdRP5HfwYxAfCzXTFKRMoIMk+n+po318+ktcChx+F1Dd91G3YHeMKyg== - throttleit@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" @@ -22205,11 +21060,6 @@ timeago.js@^4.0.2: resolved "https://registry.npmjs.org/timeago.js/-/timeago.js-4.0.2.tgz#724e8c8833e3490676c7bb0a75f5daf20e558028" integrity sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w== -timed-out@^4.0.0, timed-out@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" - integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8= - timers-browserify@^2.0.4: version "2.0.11" resolved "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.11.tgz#800b1f3eee272e5bc53ee465a04d0e804c31211f" @@ -22235,39 +21085,25 @@ tiny-emitter@^2.0.0: resolved "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== -tiny-invariant@^1.0.6: +tiny-invariant@^1.0.2, tiny-invariant@^1.0.6: version "1.1.0" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== -tiny-lr@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab" - integrity sha512-44yhA3tsaRoMOjQQ+5v5mVdqef+kH6Qze9jTpqtVufgYjYt08zyZAwNwwVBj3i1rJMnR52IxOW0LK0vBzgAkuA== - dependencies: - body "^5.1.0" - debug "^3.1.0" - faye-websocket "~0.10.0" - livereload-js "^2.3.0" - object-assign "^4.1.0" - qs "^6.4.0" - tiny-merge-patch@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/tiny-merge-patch/-/tiny-merge-patch-0.1.2.tgz#2e8ded19c56ea15dbd3ad4ed5db1c8e5ad544c3c" integrity sha1-Lo3tGcVuoV29OtTtXbHI5a1UTDw= -tiny-warning@^1.0.2: +tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== -tmp@0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz#ee434a4e22543082e294ba6201dcc6eafefa2877" - integrity sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw== - dependencies: - rimraf "^2.6.3" +tinycolor2@^1.4.1: + version "1.4.1" + resolved "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8" + integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g= tmp@^0.0.33: version "0.0.33" @@ -22276,6 +21112,13 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" +tmp@~0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz#ee434a4e22543082e294ba6201dcc6eafefa2877" + integrity sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw== + dependencies: + rimraf "^2.6.3" + tmpl@1.0.x: version "1.0.4" resolved "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" @@ -22308,6 +21151,11 @@ to-readable-stream@^1.0.0: resolved "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== +to-readable-stream@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-2.1.0.tgz#82880316121bea662cdc226adb30addb50cb06e8" + integrity sha512-o3Qa6DGg1CEXshSdvWNX2sN4QHqg03SPq7U6jPXRahlQdl5dK8oXjkU/2/sGrnOZKeGV1zLSO8qPwyKklPPE7w== + to-regex-range@^2.1.0: version "2.1.1" resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" @@ -22343,11 +21191,6 @@ toidentifier@1.0.0: resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== -toml@^2.3.2: - version "2.3.6" - resolved "https://registry.npmjs.org/toml/-/toml-2.3.6.tgz#25b0866483a9722474895559088b436fd11f861b" - integrity sha512-gVweAectJU3ebq//Ferr2JUY4WKSDe5N+z0FvjDncLGyHmIDoxgY/2Ie4qfEIDm4IS7OA6Rmdm7pdEEdMcV/xQ== - toposort@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" @@ -22411,13 +21254,6 @@ tree-kill@^1.2.2: resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== -tree-node-cli@^1.2.5: - version "1.4.0" - resolved "https://registry.npmjs.org/tree-node-cli/-/tree-node-cli-1.4.0.tgz#8f4028554610d6ee1cdeb98554a60841a3cfa3ac" - integrity sha512-hBc/cp7rTSHFSFvaTzmHNYyJv87UJBsxsfCoq2DtDQuMES4vhnLuvXZit/asGtZG8edWTCydWeFWoBz9LYkJdQ== - dependencies: - commander "^5.0.0" - trim-newlines@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" @@ -22433,13 +21269,6 @@ trim-off-newlines@^1.0.0: resolved "https://registry.npmjs.org/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz#9f9ba9d9efa8764c387698bcbfeb2c848f11adb3" integrity sha1-n5up2e+odkw4dpi8v+sshI8RrbM= -trim-repeated@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz#e3646a2ea4e891312bf7eace6cfb05380bc01c21" - integrity sha1-42RqLqTokTEr9+rObPsFOAvAHCE= - dependencies: - escape-string-regexp "^1.0.2" - trim-trailing-lines@^1.0.0: version "1.1.3" resolved "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.3.tgz#7f0739881ff76657b7776e10874128004b625a94" @@ -22460,20 +21289,12 @@ trough@^1.0.0: resolved "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406" integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA== -truncate-html@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/truncate-html/-/truncate-html-1.0.3.tgz#0166dfc7890626130c2e4174c6b73d4d63993e5f" - integrity sha512-1o1prdRv+iehXcGwn29YgXU17DotHkr+OK3ijVEG7FGMwHNG9RyobXwimw6djDvbIc24rhmz3tjNNvNESjkNkQ== - dependencies: - "@types/cheerio" "^0.22.8" - cheerio "0.22.0" - tryer@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== -ts-dedent@^1.1.0: +ts-dedent@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/ts-dedent/-/ts-dedent-1.1.1.tgz#68fad040d7dbd53a90f545b450702340e17d18f3" integrity sha512-UGTRZu1evMw4uTPyYF66/KFd22XiU+jMaIuHrkIHQ2GivAXVlLV0v/vHrpOuTRf9BmpNHi/SO7Vd0rLu0y57jg== @@ -22483,6 +21304,11 @@ ts-easing@^0.2.0: resolved "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz#c8a8a35025105566588d87dbda05dd7fbfa5a4ec" integrity sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ== +ts-essentials@^2.0.3: + version "2.0.12" + resolved "https://registry.npmjs.org/ts-essentials/-/ts-essentials-2.0.12.tgz#c9303f3d74f75fa7528c3d49b80e089ab09d8745" + integrity sha512-3IVX4nI6B5cc31/GFFE+i8ey/N2eA0CZDbo6n0yrz0zDX8ZJ8djmU1p+XRz7G3is0F3bB3pu2pAroFdAWQKU3w== + ts-interface-checker@^0.1.9: version "0.1.10" resolved "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.10.tgz#b68a49e37e90a05797e590f08494dd528bf383cf" @@ -22538,10 +21364,10 @@ ts-node@^8.10.2, ts-node@^8.6.2: source-map-support "^0.5.17" yn "3.1.1" -ts-pnp@^1.1.2: - version "1.1.6" - resolved "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.1.6.tgz#389a24396d425a0d3162e96d2b4638900fdc289a" - integrity sha512-CrG5GqAAzMT7144Cl+UIFP7mz/iIhiy+xQ6GGcnjTezhALT02uPMRw7tgDSESgB5MsfKt55+GPWw4ir1kVtMIQ== +ts-pnp@^1.1.6: + version "1.2.0" + resolved "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" + integrity sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw== tsconfig-paths@^3.9.0: version "3.9.0" @@ -22616,6 +21442,11 @@ type-detect@4.0.8: resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +type-fest@^0.10.0: + version "0.10.0" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.10.0.tgz#7f06b2b9fbfc581068d1341ffabd0349ceafc642" + integrity sha512-EUV9jo4sffrwlg8s0zDhP0T2WD3pru5Xi0+HTE3zTUmBaZNhfkite9PdSJwdXLwPVW0jnAHT56pZHIOYckPEiw== + type-fest@^0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1" @@ -22672,9 +21503,9 @@ typedarray@^0.0.6: integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= typescript@^3.9.3: - version "3.9.5" - resolved "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz#586f0dba300cde8be52dd1ac4f7e1009c1b13f36" - integrity sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ== + version "3.9.7" + resolved "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz#98d600a5ebdc38f40cb277522f12dc800e9e25fa" + integrity sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw== ua-parser-js@^0.7.18: version "0.7.21" @@ -22709,14 +21540,6 @@ umask@^1.1.0: resolved "https://registry.npmjs.org/umask/-/umask-1.1.0.tgz#f29cebf01df517912bb58ff9c4e50fde8e33320d" integrity sha1-8pzr8B31F5ErtY/5xOUP3o4zMg0= -unbzip2-stream@^1.0.9: - version "1.4.3" - resolved "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" - integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== - dependencies: - buffer "^5.2.1" - through "^2.3.8" - unc-path-regex@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" @@ -22903,7 +21726,7 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" -untildify@4.0.0: +untildify@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== @@ -22939,7 +21762,7 @@ upper-case@2.0.1, upper-case@^2.0.1: dependencies: tslib "^1.10.0" -uri-js@^4.2.2: +uri-js@^4.2.1, uri-js@^4.2.2: version "4.2.2" resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== @@ -22951,16 +21774,7 @@ urix@^0.1.0: resolved "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= -url-loader@^2.0.1: - version "2.3.0" - resolved "https://registry.npmjs.org/url-loader/-/url-loader-2.3.0.tgz#e0e2ef658f003efb8ca41b0f3ffbf76bab88658b" - integrity sha512-goSdg8VY+7nPZKUEChZSEtW5gjbS66USIGCeSJ1OVOJ7Yfuh/36YxCwMi5HVEJh6mqUYOoy3NJ0vlOMrWsSHog== - dependencies: - loader-utils "^1.2.3" - mime "^2.4.4" - schema-utils "^2.5.0" - -url-loader@^4.1.0: +url-loader@^4.0.0, url-loader@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/url-loader/-/url-loader-4.1.0.tgz#c7d6b0d6b0fccd51ab3ffc58a78d32b8d89a7be2" integrity sha512-IzgAAIC8wRrg6NYkFIJY09vtktQcsvU8V6HhtQj9PTefbYImzLB1hufqo4m+RyM5N3mLx5BqJKccgxJS+W3kqw== @@ -22969,13 +21783,6 @@ url-loader@^4.1.0: mime-types "^2.1.26" schema-utils "^2.6.5" -url-parse-lax@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73" - integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM= - dependencies: - prepend-http "^1.0.1" - url-parse-lax@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" @@ -22991,12 +21798,12 @@ url-parse@^1.4.3, url-parse@^1.4.7: querystringify "^2.1.1" requires-port "^1.0.0" -url-to-options@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9" - integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k= +url-value-parser@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.0.1.tgz#c8179a095ab9ec1f5aa17ca36af5af396b4e95ed" + integrity sha512-bexECeREBIueboLGM3Y1WaAzQkIn+Tca/Xjmjmfd0S/hFHSCEoFkNh0/D0l9G4K74MkEP/lLFRlYnxX3d68Qgw== -url@0.11.0, url@^0.11.0, url@~0.11.0: +url@^0.11.0, url@~0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= @@ -23004,24 +21811,30 @@ url@0.11.0, url@^0.11.0, url@~0.11.0: punycode "1.3.2" querystring "0.2.0" -use-callback-ref@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.2.1.tgz#898759ccb9e14be6c7a860abafa3ffbd826c89bb" - integrity sha512-C3nvxh0ZpaOxs9RCnWwAJ+7bJPwQI8LHF71LzbQ3BvzH5XkdtlkMadqElGevg5bYBDFip4sAnD4m06zAKebg1w== +use-composed-ref@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.0.0.tgz#bb13e8f4a0b873632cde4940abeb88b92d03023a" + integrity sha512-RVqY3NFNjZa0xrmK3bIMWNmQ01QjKPDc7DeWR3xa/N8aliVppuutOE5bZzPkQfvL+5NRWMMp0DJ99Trd974FIw== + dependencies: + ts-essentials "^2.0.3" + +use-isomorphic-layout-effect@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.0.0.tgz#f56b4ed633e1c21cd9fc76fe249002a1c28989fb" + integrity sha512-JMwJ7Vd86NwAt1jH7q+OIozZSIxA4ND0fx6AsOe2q1H8ooBUp5aN6DvVCqZiIaYU6JaMRJGyR0FO7EBCIsb/Rg== + +use-latest@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/use-latest/-/use-latest-1.1.0.tgz#7bf9684555869c3f5f37e10d0884c8accf4d3aa6" + integrity sha512-gF04d0ZMV3AMB8Q7HtfkAWe+oq1tFXP6dZKwBHQF5nVXtGsh2oAYeeqma5ZzxtlpOcW8Ro/tLcfmEodjDeqtuw== + dependencies: + use-isomorphic-layout-effect "^1.0.0" use-memo-one@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.1.tgz#39e6f08fe27e422a7d7b234b5f9056af313bd22c" integrity sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ== -use-sidecar@^1.0.1: - version "1.0.2" - resolved "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.0.2.tgz#e72f582a75842f7de4ef8becd6235a4720ad8af6" - integrity sha512-287RZny6m5KNMTb/Kq9gmjafi7lQL0YHO1lYolU6+tY1h9+Z3uCtkJJ3OSOq3INwYf2hBryCcDh4520AhJibMA== - dependencies: - detect-node "^2.0.4" - tslib "^1.9.3" - use@^3.1.0: version "3.1.1" resolved "https://registry.npmjs.org/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" @@ -23091,7 +21904,7 @@ uuid@^7.0.3: resolved "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== -uuid@^8.0.0, uuid@^8.2.0: +uuid@^8.0.0, uuid@^8.2.0, uuid@^8.3.0: version "8.3.0" resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz#ab738085ca22dc9a8c92725e459b1d507df5d6ea" integrity sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ== @@ -23101,19 +21914,19 @@ v8-compile-cache@^2.0.3: resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g== -v8-to-istanbul@^4.1.3: - version "4.1.4" - resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-4.1.4.tgz#b97936f21c0e2d9996d4985e5c5156e9d4e49cd6" - integrity sha512-Rw6vJHj1mbdK8edjR7+zuJrpDtKIgNdAvTSAcpYfgMIw+u2dPDntD3dgN4XQFLU2/fvFQdzj+EeSGfd/jnY5fQ== +v8-to-istanbul@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-5.0.1.tgz#0608f5b49a481458625edb058488607f25498ba5" + integrity sha512-mbDNjuDajqYe3TXFk5qxcQy8L1msXNE37WTlLoqqpBfRsimbNcrlhQlDPntmECEcUvdC+AQ8CyMMf6EUx1r74Q== dependencies: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" source-map "^0.7.3" -v8flags@^3.1.3: - version "3.1.3" - resolved "https://registry.npmjs.org/v8flags/-/v8flags-3.1.3.tgz#fc9dc23521ca20c5433f81cc4eb9b3033bb105d8" - integrity sha512-amh9CCg3ZxkzQ48Mhcb8iX7xpAfYJgePHxWMQCBWECpOSqJUXgY26ncA61UTV0BkPqfhcy6mzwCIoP4ygxpW8w== +v8flags@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz#b243e3b4dfd731fa774e7492128109a0fe66d656" + integrity sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg== dependencies: homedir-polyfill "^1.0.1" @@ -23167,6 +21980,11 @@ validate.io-number@^1.0.3: resolved "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz#f63ffeda248bf28a67a8d48e0e3b461a1665baf8" integrity sha1-9j/+2iSL8opnqNSODjtGGhZluvg= +value-equal@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" + integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== + vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -23265,14 +22083,23 @@ warning@^4.0.2, warning@^4.0.3: dependencies: loose-envify "^1.0.0" -watchpack@^1.6.1: - version "1.6.1" - resolved "https://registry.npmjs.org/watchpack/-/watchpack-1.6.1.tgz#280da0a8718592174010c078c7585a74cd8cd0e2" - integrity sha512-+IF9hfUFOrYOOaKyfaI7h7dquUIOgyEMoQMLA7OP5FxegKA2+XdXThAZ9TU2kucfhDH7rfMHs1oPYziVGWRnZA== +watchpack-chokidar2@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz#9948a1866cbbd6cb824dea13a7ed691f6c8ddff0" + integrity sha512-9TyfOyN/zLUbA288wZ8IsMZ+6cbzvsNyEzSBp6e/zkifi6xxbl8SmQ/CxQq32k8NNqrdVEVUVSEf56L4rQ/ZxA== dependencies: chokidar "^2.1.8" + +watchpack@^1.7.4: + version "1.7.4" + resolved "https://registry.npmjs.org/watchpack/-/watchpack-1.7.4.tgz#6e9da53b3c80bb2d6508188f5b200410866cd30b" + integrity sha512-aWAgTW4MoSJzZPAicljkO1hsi1oKj/RRq/OJQh2PKI2UKL04c2Bs+MBOB+BBABHTXJpf9mCwHN7ANCvYsvY2sg== + dependencies: graceful-fs "^4.1.2" neo-async "^2.5.0" + optionalDependencies: + chokidar "^3.4.1" + watchpack-chokidar2 "^2.0.0" wbuf@^1.1.0, wbuf@^1.7.3: version "1.7.3" @@ -23288,6 +22115,13 @@ wcwidth@^1.0.0, wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +webapi-parser@^0.5.0: + version "0.5.0" + resolved "https://registry.npmjs.org/webapi-parser/-/webapi-parser-0.5.0.tgz#2632185c5d8f3e6addb2520857af89ea9844dc14" + integrity sha512-fPt6XuMqLSvBz8exwX4QE1UT+pROLHa00EMDCdO0ybICduwQ1V4f7AWX4pNOpCp+x+0FjczEsOxtQU0d8L3QKw== + dependencies: + ajv "6.5.2" + webidl-conversions@^3.0.0, webidl-conversions@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -23368,6 +22202,16 @@ webpack-hot-middleware@^2.25.0: querystring "^0.2.0" strip-ansi "^3.0.0" +webpack-log@^1.1.2: + version "1.2.0" + resolved "https://registry.npmjs.org/webpack-log/-/webpack-log-1.2.0.tgz#a4b34cda6b22b518dbb0ab32e567962d5c72a43d" + integrity sha512-U9AnICnu50HXtiqiDxuli5gLB5PGBo7VvcHx36jRZHwK4vzOYLbImqT4lwWwoMHdQWwEKw736fCHEekokTEKHA== + dependencies: + chalk "^2.1.0" + log-symbols "^2.1.0" + loglevelnext "^1.0.1" + uuid "^3.1.0" + webpack-log@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/webpack-log/-/webpack-log-2.0.0.tgz#5b7928e0637593f119d32f6227c1e0ac31e1b47f" @@ -23376,10 +22220,10 @@ webpack-log@^2.0.0: ansi-colors "^3.0.0" uuid "^3.3.2" -webpack-node-externals@^1.7.2: - version "1.7.2" - resolved "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-1.7.2.tgz#6e1ee79ac67c070402ba700ef033a9b8d52ac4e3" - integrity sha512-ajerHZ+BJKeCLviLUUmnyd5B4RavLF76uv3cs6KNuO8W+HuQaEs0y0L7o40NQxdPy5w0pcv8Ew7yPUAQG0UdCg== +webpack-node-externals@^2.5.0: + version "2.5.2" + resolved "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-2.5.2.tgz#178e017a24fec6015bc9e672c77958a6afac861d" + integrity sha512-aHdl/y2N7PW2Sx7K+r3AxpJO+aDMcYzMQd60Qxefq3+EwhewSbTBqNumOsCE1JsCUNoyfGj5465N0sSf6hc/5w== webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: version "1.4.3" @@ -23389,17 +22233,17 @@ webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack- source-list-map "^2.0.0" source-map "~0.6.1" -webpack-virtual-modules@^0.2.0: - version "0.2.1" - resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.1.tgz#8ab73d4df0fd37ed27bb8d823bc60ea7266c8bf7" - integrity sha512-0PWBlxyt4uGDofooIEanWhhyBOHdd+lr7QpYNDLC7/yc5lqJT8zlc04MTIBnKj+c2BlQNNuwE5er/Tg4wowHzA== +webpack-virtual-modules@^0.2.2: + version "0.2.2" + resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.2.tgz#20863dc3cb6bb2104729fff951fbe14b18bd0299" + integrity sha512-kDUmfm3BZrei0y+1NTHJInejzxfhtU8eDj2M7OKb2IWrPFAeO1SOH2KuQ68MSZu9IGEHcxbkKKR1v18FrUSOmA== dependencies: debug "^3.0.0" -webpack@^4.33.0, webpack@^4.38.0, webpack@^4.41.6: - version "4.43.0" - resolved "https://registry.npmjs.org/webpack/-/webpack-4.43.0.tgz#c48547b11d563224c561dad1172c8aa0b8a678e6" - integrity sha512-GW1LjnPipFW2Y78OOab8NJlCflB7EFskMih2AHdvjbpKMeDJqEgSx24cXXXiPS65+WSwVyxtDsJH6jGX2czy+g== +webpack@^4.41.6, webpack@^4.43.0: + version "4.44.1" + resolved "https://registry.npmjs.org/webpack/-/webpack-4.44.1.tgz#17e69fff9f321b8f117d1fda714edfc0b939cc21" + integrity sha512-4UOGAohv/VGUNQJstzEywwNxqX417FnjZgZJpJQegddzPmTvph37eBIRbRTfdySXzVtJXLJfbMN3mMYhM6GdmQ== dependencies: "@webassemblyjs/ast" "1.9.0" "@webassemblyjs/helper-module-context" "1.9.0" @@ -23409,7 +22253,7 @@ webpack@^4.33.0, webpack@^4.38.0, webpack@^4.41.6: ajv "^6.10.2" ajv-keywords "^3.4.1" chrome-trace-event "^1.0.2" - enhanced-resolve "^4.1.0" + enhanced-resolve "^4.3.0" eslint-scope "^4.0.3" json-parse-better-errors "^1.0.2" loader-runner "^2.4.0" @@ -23422,7 +22266,7 @@ webpack@^4.33.0, webpack@^4.38.0, webpack@^4.41.6: schema-utils "^1.0.0" tapable "^1.1.3" terser-webpack-plugin "^1.4.3" - watchpack "^1.6.1" + watchpack "^1.7.4" webpack-sources "^1.4.1" websocket-driver@0.6.5: @@ -23464,21 +22308,21 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3, whatwg-encoding@^1.0.5: dependencies: iconv-lite "0.4.24" -whatwg-fetch@2.0.4, whatwg-fetch@^2.0.0, whatwg-fetch@^2.0.4: +whatwg-fetch@2.0.4, whatwg-fetch@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng== -whatwg-fetch@3.0.0, whatwg-fetch@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" - integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q== - whatwg-fetch@>=0.10.0: version "3.3.1" resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.3.1.tgz#6c1acf37dec176b0fd6bc9a74b616bec2f612935" integrity sha512-faXTmGDcLuEPBpJwb5LQfyxvubKiE+RlbmmweFGKjvIPFj4uHTTfdtTIkdTRhC6OSH9S9eyYbx8kZ0UEaQqYTA== +whatwg-fetch@^3.0.0, whatwg-fetch@^3.4.0: + version "3.4.1" + resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz#e5f871572d6879663fa5674c8f833f15a8425ab3" + integrity sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ== + whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0, whatwg-mimetype@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" @@ -23602,11 +22446,6 @@ word-wrap@^1.2.3, word-wrap@~1.2.3: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== -wordwrap@0.0.2: - version "0.0.2" - resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" - integrity sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8= - wordwrap@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" @@ -23756,6 +22595,11 @@ x-is-string@^0.1.0: resolved "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82" integrity sha1-R0tQhlrzpJqcRlfwWs0UVFj3fYI= +xcase@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/xcase/-/xcase-2.0.1.tgz#c7fa72caa0f440db78fd5673432038ac984450b9" + integrity sha1-x/pyyqD0QNt4/VZzQyA4rJhEULk= + xdg-basedir@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" @@ -23786,13 +22630,6 @@ xml-encryption@^1.0.0: xmldom "~0.1.15" xpath "0.0.27" -xml-js@^1.6.11: - version "1.6.11" - resolved "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9" - integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g== - dependencies: - sax "^1.2.4" - "xml-name-validator@>= 2.0.1 < 3.0.0": version "2.0.1" resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-2.0.1.tgz#4d8b8f1eccd3419aa362061becef515e1e559635" @@ -23816,11 +22653,6 @@ xmlbuilder@^11.0.0, xmlbuilder@~11.0.0: resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== -xmlbuilder@^13.0.0: - version "13.0.2" - resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz#02ae33614b6a047d1c32b5389c1fdacb2bce47a7" - integrity sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ== - xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" @@ -23876,11 +22708,6 @@ yaeti@^0.0.6: resolved "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz#f26f484d72684cf42bedfb76970aa1608fbf9577" integrity sha1-8m9ITXJoTPQr7ft2lwqhYI+/lXc= -yallist@^2.1.2: - version "2.1.2" - resolved "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" - integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= - yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3: version "3.1.1" resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" @@ -23891,6 +22718,11 @@ yallist@^4.0.0: resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yaml-ast-parser@0.0.43: + version "0.0.43" + resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz#e8a23e6fb4c38076ab92995c5dca33f3d3d7c9bb" + integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== + yaml-ast-parser@^0.0.40: version "0.0.40" resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.40.tgz#08536d4e73d322b1c9ce207ab8dd70e04d20ae6e" @@ -23901,15 +22733,7 @@ yaml@*, yaml@^1.10.0, yaml@^1.7.2, yaml@^1.9.2: resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== -yamljs@^0.2.1: - version "0.2.10" - resolved "https://registry.npmjs.org/yamljs/-/yamljs-0.2.10.tgz#481cc7c25ca73af59f591f0c96e3ce56c757a40f" - integrity sha1-SBzHwlynOvWfWR8MluPOVsdXpA8= - dependencies: - argparse "^1.0.7" - glob "^7.0.5" - -yargs-parser@18.x, yargs-parser@^18.1.1, yargs-parser@^18.1.2: +yargs-parser@18.x, yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== @@ -23948,7 +22772,7 @@ yargs-parser@^3.2.0: camelcase "^3.0.0" lodash.assign "^4.1.0" -yargs@15.4.1: +yargs@15.4.1, yargs@^15.3.1, yargs@^15.4.1: version "15.4.1" resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== @@ -23998,30 +22822,6 @@ yargs@^14.2.2: y18n "^4.0.0" yargs-parser "^15.0.1" -yargs@^15.3.1: - version "15.3.1" - resolved "https://registry.npmjs.org/yargs/-/yargs-15.3.1.tgz#9505b472763963e54afe60148ad27a330818e98b" - integrity sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA== - dependencies: - cliui "^6.0.0" - decamelize "^1.2.0" - find-up "^4.1.0" - get-caller-file "^2.0.1" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^4.2.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^18.1.1" - -yargs@^2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/yargs/-/yargs-2.3.0.tgz#e900c87250ec5cd080db6009fe3dd63156f1d7fb" - integrity sha1-6QDIclDsXNCA22AJ/j3WMVbx1/s= - dependencies: - wordwrap "0.0.2" - yargs@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/yargs/-/yargs-5.0.0.tgz#3355144977d05757dbb86d6e38ec056123b3a66e" @@ -24042,7 +22842,7 @@ yargs@^5.0.0: y18n "^3.2.1" yargs-parser "^3.2.0" -yauzl@2.10.0, yauzl@^2.10.0, yauzl@^2.4.2: +yauzl@^2.10.0: version "2.10.0" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk= @@ -24069,16 +22869,16 @@ yn@^4.0.0: integrity sha512-huWiiCS4TxKc4SfgmTwW1K7JmXPPAmuXWYy4j9qjQo4+27Kni8mGhAAi1cloRWmBe2EqcLgt3IGqQoRL/MtPgg== yup@^0.29.1: - version "0.29.1" - resolved "https://registry.npmjs.org/yup/-/yup-0.29.1.tgz#35d25aab470a0c3950f66040ba0ff4b1b6efe0d9" - integrity sha512-U7mPIbgfQWI6M3hZCJdGFrr+U0laG28FxMAKIgNvgl7OtyYuUoc4uy9qCWYHZjh49b8T7Ug8NNDdiMIEytcXrQ== + version "0.29.3" + resolved "https://registry.npmjs.org/yup/-/yup-0.29.3.tgz#69a30fd3f1c19f5d9e31b1cf1c2b851ce8045fea" + integrity sha512-RNUGiZ/sQ37CkhzKFoedkeMfJM0vNQyaz+wRZJzxdKE7VfDeVKH8bb4rr7XhRLbHJz5hSjoDNwMEIaKhuMZ8gQ== dependencies: - "@babel/runtime" "^7.9.6" + "@babel/runtime" "^7.10.5" fn-name "~3.0.0" lodash "^4.17.15" lodash-es "^4.17.11" property-expr "^2.0.2" - synchronous-promise "^2.0.10" + synchronous-promise "^2.0.13" toposort "^2.0.2" zen-observable-ts@^0.8.21: