diff --git a/packages/app/package.json b/packages/app/package.json index 118b4c15ff..46769e7dfb 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -76,13 +76,6 @@ "pathRewrite": { "^/circleci/api/": "/" } - }, - "/catalog/api": { - "target": "http://localhost:7000", - "changeOrigin": true, - "pathRewrite": { - "^/catalog/api/": "/catalog/" - } } } } diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 65e8ff64e6..26c3466ba1 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -26,6 +26,7 @@ describe('App', () => { { data: { app: { title: 'Test' }, + backend: { baseUrl: 'http://localhost:7000' }, }, context: 'test', }, diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 1565dc294e..61104ddadf 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -27,11 +27,13 @@ import { GoogleAuth, GithubAuth, OktaAuth, + GitlabAuth, oauthRequestApiRef, OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, oktaAuthApiRef, + gitlabAuthApiRef, storageApiRef, WebStorage, } from '@backstage/core'; @@ -51,15 +53,14 @@ import { graphQlBrowseApiRef, GraphQLEndpoints, } from '@backstage/plugin-graphiql'; -import { - scaffolderApiRef, - ScaffolderApi, -} from '@backstage/plugin-scaffolder/src/api'; +import { scaffolderApiRef, ScaffolderApi } from '@backstage/plugin-scaffolder'; 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()); @@ -82,7 +83,7 @@ export const apis = (config: ConfigApi) => { builder.add( googleAuthApiRef, GoogleAuth.create({ - apiOrigin: 'http://localhost:7000', + apiOrigin: backendUrl, basePath: '/auth/', oauthRequestApi, }), @@ -91,7 +92,7 @@ export const apis = (config: ConfigApi) => { const githubAuthApi = builder.add( githubAuthApiRef, GithubAuth.create({ - apiOrigin: 'http://localhost:7000', + apiOrigin: backendUrl, basePath: '/auth/', oauthRequestApi, }), @@ -100,6 +101,15 @@ export const apis = (config: ConfigApi) => { builder.add( oktaAuthApiRef, OktaAuth.create({ + apiOrigin: backendUrl, + basePath: '/auth/', + oauthRequestApi, + }), + ); + + builder.add( + gitlabAuthApiRef, + GitlabAuth.create({ apiOrigin: 'http://localhost:7000', basePath: '/auth/', oauthRequestApi, @@ -117,7 +127,7 @@ export const apis = (config: ConfigApi) => { builder.add( catalogApiRef, new CatalogClient({ - apiOrigin: 'http://localhost:7000', + apiOrigin: backendUrl, basePath: '/catalog', }), ); @@ -125,7 +135,7 @@ export const apis = (config: ConfigApi) => { builder.add( scaffolderApiRef, new ScaffolderApi({ - apiOrigin: 'http://localhost:7000', + apiOrigin: backendUrl, basePath: '/scaffolder/v1', }), ); diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index a944e85a7e..c9a17f0153 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -242,11 +242,24 @@ export const githubAuthApiRef = createApiRef< */ export const oktaAuthApiRef = createApiRef< OAuthApi & - OpenIdConnectApi & - ProfileInfoApi & - BackstageIdentityApi & - SessionStateApi + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionStateApi >({ id: 'core.auth.okta', description: 'Provides authentication towards Okta 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. + */ +export const gitlabAuthApiRef = createApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi +>({ + id: 'core.auth.gitlab', + description: 'Provides authentication towards Gitlab APIs', +}); 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 ddf03034dc..9309cfd437 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -27,7 +27,10 @@ import { } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { + AuthSessionStore, + StaticAuthSessionManager, +} from '../../../../lib/AuthSessionManager'; import { Observable } from '../../../../types'; type CreateOptions = { @@ -91,7 +94,13 @@ class GithubAuth implements OAuthApi, SessionStateApi { sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }); - return new GithubAuth(sessionManager); + const authSessionStore = new AuthSessionStore({ + manager: sessionManager, + storageKey: 'githubSession', + sessionScopes: (session: GithubSession) => session.providerInfo.scopes, + }); + + return new GithubAuth(authSessionStore); } sessionState$(): Observable { 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 new file mode 100644 index 0000000000..18346b3208 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.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 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); + + expect(await gitlabAuth.getAccessToken()).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + }); + + 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']), + }, + ]; + + for (const test of tests) { + expect(GitlabAuth.normalizeScope(...test.arguments)).toEqual(test.expect); + } + }); +}); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts new file mode 100644 index 0000000000..20e13f8a03 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.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 GitlabIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { GitlabSession } from './types'; +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'; + +type CreateOptions = { + apiOrigin: string; + basePath: string; + + 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 { + static create({ + apiOrigin, + basePath, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + apiOrigin, + basePath, + environment, + provider, + 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, + ), + }, + }; + }, + }); + + 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 new file mode 100644 index 0000000000..ee17b7de06 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/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 './types'; +export { default as GitlabAuth } from './GitlabAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/types.ts b/packages/core-api/src/apis/implementations/auth/gitlab/types.ts new file mode 100644 index 0000000000..b03dfec084 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/types.ts @@ -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 { ProfileInfo, BackstageIdentity } from '../../../definitions'; + +export type GitlabSession = { + providerInfo: { + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index 4fa992dfb5..9d89ba5b04 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -16,4 +16,5 @@ export * from './google'; export * from './github'; +export * from './gitlab'; export * from './okta'; diff --git a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts index 3e3a40d02a..f2b8558f74 100644 --- a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts +++ b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -36,6 +36,8 @@ type Options = { /** * AuthSessionStore decorates another SessionManager with a functionality * to store the session in local storage. + * + * Session is serialized to JSON with special support for following types: Set. */ export class AuthSessionStore implements SessionManager { private readonly manager: SessionManager; @@ -90,7 +92,12 @@ export class AuthSessionStore implements SessionManager { try { const sessionJson = localStorage.getItem(this.storageKey); if (sessionJson) { - const session = JSON.parse(sessionJson); + const session = JSON.parse(sessionJson, (_key, value) => { + if (value?.__type === 'Set') { + return new Set(value.__value); + } + return value; + }); return session; } @@ -105,7 +112,18 @@ export class AuthSessionStore implements SessionManager { if (session === undefined) { localStorage.removeItem(this.storageKey); } else { - localStorage.setItem(this.storageKey, JSON.stringify(session)); + localStorage.setItem( + this.storageKey, + JSON.stringify(session, (_key, value) => { + if (value instanceof Set) { + return { + __type: 'Set', + __value: Array.from(value), + }; + } + return value; + }), + ); } } } diff --git a/packages/core-api/src/lib/AuthSessionManager/index.ts b/packages/core-api/src/lib/AuthSessionManager/index.ts index 16a8d3c378..5f4dde8662 100644 --- a/packages/core-api/src/lib/AuthSessionManager/index.ts +++ b/packages/core-api/src/lib/AuthSessionManager/index.ts @@ -16,4 +16,5 @@ export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; export { StaticAuthSessionManager } from './StaticAuthSessionManager'; +export { AuthSessionStore } from './AuthSessionStore'; export * from './types'; diff --git a/packages/core/src/components/Table/Table.stories.tsx b/packages/core/src/components/Table/Table.stories.tsx index 3709c301fd..11fa0c44dc 100644 --- a/packages/core/src/components/Table/Table.stories.tsx +++ b/packages/core/src/components/Table/Table.stories.tsx @@ -185,3 +185,38 @@ export const SubvalueTable = () => { ); }; + +export const DenseTable = () => { + const columns: TableColumn[] = [ + { + title: 'Column 1', + field: 'col1', + highlight: true, + }, + { + title: 'Column 2', + field: 'col2', + }, + { + title: 'Numeric value', + field: 'number', + type: 'numeric', + }, + { + title: 'A Date', + field: 'date', + type: 'date', + }, + ]; + + return ( +
+ + + ); +}; diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index 047a6b1990..89aa2dce1f 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -35,7 +35,6 @@ import ViewColumn from '@material-ui/icons/ViewColumn'; import MTable, { Column, MaterialTableProps, - MTableCell, MTableHeader, MTableToolbar, Options, @@ -96,14 +95,6 @@ const tableIcons = { )), }; -const useCellStyles = makeStyles(theme => ({ - root: { - color: theme.palette.grey[500], - padding: theme.spacing(0, 2, 0, 2.5), - height: '56px', - }, -})); - const useHeaderStyles = makeStyles(theme => ({ header: { padding: theme.spacing(1, 2, 1, 2.5), @@ -169,7 +160,6 @@ export function Table({ subtitle, ...props }: TableProps) { - const cellClasses = useCellStyles(); const headerClasses = useHeaderStyles(); const toolbarClasses = useToolbarStyles(); const theme = useTheme(); @@ -185,9 +175,6 @@ export function Table({ return ( components={{ - Cell: cellProps => ( - - ), Header: headerProps => ( ), diff --git a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx new file mode 100644 index 0000000000..46b93da370 --- /dev/null +++ b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx @@ -0,0 +1,67 @@ +/* + * 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 { ItemCard } from '.'; +import { Grid } from '@material-ui/core'; + +export default { + title: 'Item Card', + component: ItemCard, +}; + +export const Default = () => ( + + + {}} + /> + + + {}} + /> + + +); + +export const Tags = () => ( + + + + + + + + +); diff --git a/plugins/techdocs/src/reader/components/DocsCard.tsx b/packages/core/src/layout/ItemCard/ItemCard.tsx similarity index 96% rename from plugins/techdocs/src/reader/components/DocsCard.tsx rename to packages/core/src/layout/ItemCard/ItemCard.tsx index 68f42bf84a..86ad53e8dd 100644 --- a/plugins/techdocs/src/reader/components/DocsCard.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.tsx @@ -37,7 +37,7 @@ const useStyles = makeStyles(theme => ({ }, })); -type DocsCardProps = { +type ItemCardProps = { description: string; tags?: string[]; title: string; @@ -45,7 +45,7 @@ type DocsCardProps = { label: string; onClick?: () => void; }; -export const DocsCard: FC = ({ +export const ItemCard: FC = ({ description, tags, title, diff --git a/packages/core/src/layout/ItemCard/index.ts b/packages/core/src/layout/ItemCard/index.ts new file mode 100644 index 0000000000..b38dd2acc2 --- /dev/null +++ b/packages/core/src/layout/ItemCard/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 { ItemCard } from './ItemCard'; diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 668cb1354a..937169c8bf 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -22,6 +22,7 @@ import { SidebarContext } from './config'; import { googleAuthApiRef, githubAuthApiRef, + gitlabAuthApiRef, identityApiRef, oktaAuthApiRef, useApi, @@ -57,6 +58,11 @@ export function SidebarUserSettings() { apiRef={githubAuthApiRef} icon={Star} /> + + GitlabAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +}); diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 0d0cf74e8f..5b00a3c3e0 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -6,12 +6,14 @@ import { OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, + gitlabAuthApiRef, oktaAuthApiRef, AlertApiForwarder, ErrorApiForwarder, ErrorAlerter, GoogleAuth, GithubAuth, + GitlabAuth, OktaAuth, identityApiRef, } from '@backstage/core'; @@ -52,6 +54,15 @@ builder.add( }), ); +builder.add( + gitlabAuthApiRef, + GitlabAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +); + builder.add( oktaAuthApiRef, OktaAuth.create({ diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts index 955b182ae9..dc68db11d6 100644 --- a/packages/theme/src/baseTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -118,9 +118,12 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides { verticalAlign: 'middle', lineHeight: '1', margin: 0, - padding: '8px', + padding: theme.spacing(3, 2, 3, 2.5), borderBottom: 0, }, + sizeSmall: { + padding: theme.spacing(1, 2, 1, 2.5), + }, head: { wordBreak: 'break-word', overflow: 'hidden', diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index 147853f06f..ff0b1c7a3d 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -7,17 +7,46 @@ This is the backend part of the auth plugin. It responds to auth requests from the frontend, and fulfills them by delegating to the appropriate provider in the backend. -## Requirements - -Needs AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET set in the environment for the backend to startup - ## Local development -export AUTH_GOOGLE_CLIENT_ID= -read -r AUTH_GOOGLE_CLIENT_SECRET - -export AUTH_GOOGLE_CLIENT_SECRET -run `yarn start` in packages/backend folder +Choose your OAuth Providers, replace `x` with actual value and then start backend: +Example for Google Oauth Provider at root directory: + +```bash +export AUTH_GOOGLE_CLIENT_ID=x +export AUTH_GOOGLE_CLIENT_SECRET=x +yarn --cwd packages/backend start +``` + +### Google + +```bash +export AUTH_GOOGLE_CLIENT_ID=x +export AUTH_GOOGLE_CLIENT_SECRET=x +``` + +### Github + +```bash +export AUTH_GITHUB_CLIENT_ID=x +export AUTH_GITHUB_CLIENT_SECRET=x +``` + +### Gitlab + +```bash +export GITLAB_BASE_URL=x # default is https://gitlab.com +export AUTH_GITLAB_CLIENT_ID=x +export AUTH_GITLAB_CLIENT_SECRET=x +``` + +### Okta + +```bash +export AUTH_OKTA_AUDIENCE=x +export AUTH_OKTA_CLIENT_ID=x +export AUTH_OKTA_CLIENT_SECRET=x +``` ### SAML diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index a336441dc2..d756704a62 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -39,6 +39,7 @@ "morgan": "^1.10.0", "passport": "^0.4.1", "passport-github2": "^0.1.12", + "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "passport-oauth2": "^1.5.0", "passport-okta-oauth": "^0.0.1", diff --git a/plugins/auth-backend/src/lib/OAuthProvider.ts b/plugins/auth-backend/src/lib/OAuthProvider.ts index b7179659f9..5434b6a4ba 100644 --- a/plugins/auth-backend/src/lib/OAuthProvider.ts +++ b/plugins/auth-backend/src/lib/OAuthProvider.ts @@ -33,6 +33,7 @@ export type Options = { providerId: string; secure: boolean; disableRefresh?: boolean; + persistScopes?: boolean; baseUrl: string; appOrigin: string; tokenIssuer: TokenIssuer; @@ -105,6 +106,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers { throw new InputError('missing scope parameter'); } + if (this.options.persistScopes) { + this.setScopesCookie(res, scope); + } + const nonce = crypto.randomBytes(16).toString('base64'); // set a nonce cookie before redirecting to oauth provider this.setNonceCookie(res, nonce); @@ -137,6 +142,14 @@ export class OAuthProvider implements AuthProviderRouteHandlers { req, ); + if (this.options.persistScopes) { + const grantedScopes = this.getScopesFromCookie( + req, + this.options.providerId, + ); + response.providerInfo.scope = grantedScopes; + } + if (!this.options.disableRefresh) { // throw error if missing refresh token if (!refreshToken) { @@ -241,6 +254,21 @@ export class OAuthProvider implements AuthProviderRouteHandlers { }); }; + private setScopesCookie = (res: express.Response, scope: string) => { + res.cookie(`${this.options.providerId}-scope`, scope, { + maxAge: TEN_MINUTES_MS, + secure: this.options.secure, + sameSite: 'none', + domain: this.domain, + path: `${this.basePath}/${this.options.providerId}/handler`, + httpOnly: true, + }); + }; + + private getScopesFromCookie = (req: express.Request, providerId: string) => { + return req.cookies[`${providerId}-scope`]; + }; + private setRefreshTokenCookie = ( res: express.Response, refreshToken: string, diff --git a/plugins/auth-backend/src/lib/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/PassportStrategyHelper.ts index 1167946560..4cc0de4444 100644 --- a/plugins/auth-backend/src/lib/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/lib/PassportStrategyHelper.ts @@ -137,7 +137,7 @@ export const executeRefreshTokenStrategy = async ( params: any, ) => { if (err) { - reject(new Error(`Failed to refresh access token ${err}`)); + reject(new Error(`Failed to refresh access token ${err.toString()}`)); } if (!accessToken) { reject( diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 9feefdac9f..7fa375bb6b 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -17,6 +17,7 @@ import Router from 'express-promise-router'; import { createGithubProvider } from './github'; import { createGoogleProvider } from './google'; +import { createGitlabProvider } from './gitlab'; import { createSamlProvider } from './saml'; import { createOktaProvider } from './okta'; import { AuthProviderFactory, AuthProviderConfig } from './types'; @@ -26,6 +27,7 @@ import { TokenIssuer } from '../identity'; const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, github: createGithubProvider, + gitlab: createGitlabProvider, saml: createSamlProvider, okta: createOktaProvider, }; diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index 01652a6d7e..ec444733ba 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -115,6 +115,7 @@ export function createGithubProvider( envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), { disableRefresh: true, + persistScopes: true, providerId: 'github', secure, baseUrl, diff --git a/plugins/auth-backend/src/providers/gitlab/index.ts b/plugins/auth-backend/src/providers/gitlab/index.ts new file mode 100644 index 0000000000..7fba2dd95c --- /dev/null +++ b/plugins/auth-backend/src/providers/gitlab/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 { createGitlabProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/gitlab/provider.test.ts b/plugins/auth-backend/src/providers/gitlab/provider.test.ts new file mode 100644 index 0000000000..5eec6c47f9 --- /dev/null +++ b/plugins/auth-backend/src/providers/gitlab/provider.test.ts @@ -0,0 +1,100 @@ +/* + * 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 { GitlabAuthProvider } from './provider'; + +describe('GitlabAuthProvider', () => { + it('should transform to type OAuthResponse', () => { + const tests = [ + { + arguments: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + rawProfile: { + id: 'uid-123', + username: 'jimmymarkum', + provider: 'gitlab', + displayName: 'Jimmy Markum', + emails: [ + { + value: 'jimmymarkum@gmail.com', + }, + ], + avatarUrl: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + params: { + scope: 'user_read write_repository', + expires_in: 100, + }, + }, + expect: { + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + expiresInSeconds: 100, + scope: 'user_read write_repository', + }, + profile: { + email: 'jimmymarkum@gmail.com', + displayName: 'Jimmy Markum', + picture: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + }, + }, + { + arguments: { + accessToken: + 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe', + rawProfile: { + id: 'ipd12039', + username: 'daveboyle', + provider: 'gitlab', + displayName: 'Dave Boyle', + emails: [ + { + value: 'daveboyle@gitlab.org', + }, + ], + }, + params: { + scope: 'read_repository', + }, + }, + expect: { + providerInfo: { + accessToken: + 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe', + scope: 'read_repository', + }, + profile: { + displayName: 'Dave Boyle', + email: 'daveboyle@gitlab.org', + }, + }, + }, + ]; + + for (const test of tests) { + expect( + GitlabAuthProvider.transformOAuthResponse( + test.arguments.accessToken, + test.arguments.rawProfile, + test.arguments.params, + ), + ).toEqual(test.expect); + } + }); +}); diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts new file mode 100644 index 0000000000..890fbb3531 --- /dev/null +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -0,0 +1,176 @@ +/* + * 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 { Strategy as GitlabStrategy } from 'passport-gitlab2'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + makeProfileInfo, +} from '../../lib/PassportStrategyHelper'; +import { + OAuthProviderHandlers, + AuthProviderConfig, + RedirectInfo, + EnvironmentProviderConfig, + OAuthProviderOptions, + OAuthProviderConfig, + OAuthResponse, + PassportDoneCallback, +} from '../types'; +import { OAuthProvider } from '../../lib/OAuthProvider'; +import { + EnvironmentHandlers, + EnvironmentHandler, +} from '../../lib/EnvironmentHandler'; +import { Logger } from 'winston'; +import { TokenIssuer } from '../../identity'; +import passport from 'passport'; + +export class GitlabAuthProvider implements OAuthProviderHandlers { + private readonly _strategy: GitlabStrategy; + + static transformPassportProfile(rawProfile: any): passport.Profile { + const profile: passport.Profile = { + id: rawProfile.id, + username: rawProfile.username, + provider: rawProfile.provider, + displayName: rawProfile.displayName, + }; + + if (rawProfile.emails && rawProfile.emails.length > 0) { + profile.emails = rawProfile.emails; + } + if (rawProfile.avatarUrl) { + profile.photos = [{ value: rawProfile.avatarUrl }]; + } + + return profile; + } + + static transformOAuthResponse( + accessToken: string, + rawProfile: any, + params: any = {}, + ): OAuthResponse { + const passportProfile = GitlabAuthProvider.transformPassportProfile( + rawProfile, + ); + + const profile = makeProfileInfo(passportProfile, params.id_token); + const providerInfo = { + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + idToken: params.id_token, + }; + + if (params.expires_in) { + providerInfo.expiresInSeconds = params.expires_in; + } + if (params.id_token) { + providerInfo.idToken = params.id_token; + } + return { + providerInfo, + profile, + }; + } + + constructor(options: OAuthProviderOptions) { + this._strategy = new GitlabStrategy( + { ...options }, + ( + accessToken: any, + _: any, + params: any, + rawProfile: any, + done: PassportDoneCallback, + ) => { + const oauthResponse = GitlabAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ); + done(undefined, oauthResponse); + }, + ); + } + + async start( + req: express.Request, + options: Record, + ): Promise { + return await executeRedirectStrategy(req, this._strategy, options); + } + + async handler(req: express.Request): Promise<{ response: OAuthResponse }> { + return await executeFrameHandlerStrategy( + req, + this._strategy, + ); + } +} + +export function createGitlabProvider( + { baseUrl }: AuthProviderConfig, + providerConfig: EnvironmentProviderConfig, + logger: Logger, + tokenIssuer: TokenIssuer, +) { + const envProviders: EnvironmentHandlers = {}; + + for (const [env, envConfig] of Object.entries(providerConfig)) { + const { + secure, + appOrigin, + clientId, + clientSecret, + audience, + } = (envConfig as unknown) as OAuthProviderConfig; + const callbackURLParam = `?env=${env}`; + + const opts = { + clientID: clientId, + clientSecret: clientSecret, + callbackURL: `${baseUrl}/gitlab/handler/frame${callbackURLParam}`, + baseURL: audience, + }; + + 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', + ); + continue; + } + + envProviders[env] = new OAuthProvider(new GitlabAuthProvider(opts), { + disableRefresh: true, + providerId: 'gitlab', + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); + } + return new EnvironmentHandler(envProviders); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/store/types.ts b/plugins/auth-backend/src/providers/gitlab/types.d.ts similarity index 60% rename from plugins/scaffolder-backend/src/scaffolder/stages/store/types.ts rename to plugins/auth-backend/src/providers/gitlab/types.d.ts index ae63a05197..8cd7373c52 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/store/types.ts +++ b/plugins/auth-backend/src/providers/gitlab/types.d.ts @@ -13,11 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TemplateEntityV1alpha1 } from "@backstage/catalog-model"; -import { RequiredTemplateValues } from "../templater"; -import { JsonValue } from "@backstage/config"; -export type Storer = { - createRemote(opts: { entity: TemplateEntityV1alpha1, values: RequiredTemplateValues & Record}): Promise; - pushToRemote(directory: string, remote: string): Promise; +declare module 'passport-gitlab2' { + import { Request } from 'express'; + import { StrategyCreated } from 'passport'; + + export class Strategy { + constructor(options: any, verify: any); + authenticate(this: StrategyCreated, req: Request, options?: any): any; + } } diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index b9c0ff4496..fb533a4bdd 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -76,6 +76,15 @@ export async function createRouter( clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!, }, }, + gitlab: { + development: { + appOrigin: 'http://localhost:3000', + secure: false, + clientId: process.env.AUTH_GITLAB_CLIENT_ID!, + clientSecret: process.env.AUTH_GITLAB_CLIENT_SECRET!, + audience: process.env.GITLAB_BASE_URL! || 'https://gitlab.com', + }, + }, saml: { development: { entryPoint: 'http://localhost:7001/', @@ -89,8 +98,8 @@ export async function createRouter( clientId: process.env.AUTH_OKTA_CLIENT_ID!, clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET!, audience: process.env.AUTH_OKTA_AUDIENCE, - } - } + }, + }, }, }, }; diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 3804315ace..65ee8a065e 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -70,9 +70,9 @@ export class CatalogClient implements CatalogApi { return await this.getOptional(`/locations/${id}`); } - async getEntities( + async getEntities( filter?: Record, - ): Promise { + ): Promise { let path = `/entities`; if (filter) { const params = new URLSearchParams(); diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index bfb0328909..67a76ef872 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -28,7 +28,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "^5.2.0", + "react-router-dom": "6.0.0-alpha.5", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/gitops-profiles/src/api.ts b/plugins/gitops-profiles/src/api.ts index 20e4dc4307..3a14dea837 100644 --- a/plugins/gitops-profiles/src/api.ts +++ b/plugins/gitops-profiles/src/api.ts @@ -79,6 +79,14 @@ export interface ListClusterRequest { gitHubToken: string; } +export interface GithubUserInfoRequest { + accessToken: string; +} + +export interface GithubUserInfoResponse { + login: string; +} + export class FetchError extends Error { get name(): string { return this.constructor.name; @@ -100,6 +108,7 @@ export type GitOpsApi = { cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise; applyProfiles(req: ApplyProfileRequest): Promise; listClusters(req: ListClusterRequest): Promise; + fetchUserInfo(req: GithubUserInfoRequest): Promise; }; export const gitOpsApiRef = createApiRef({ @@ -116,6 +125,19 @@ export class GitOpsRestApi implements GitOpsApi { return await resp.json(); } + async fetchUserInfo( + req: GithubUserInfoRequest, + ): Promise { + const resp = await fetch(`https://api.github.com/user`, { + method: 'get', + headers: new Headers({ + Authorization: `token ${req.accessToken}`, + }), + }); + if (!resp.ok) throw await FetchError.forResponse(resp); + return await resp.json(); + } + async fetchLog(req: PollLogRequest): Promise { return await this.fetch(`/api/cluster/run-status`, { method: 'post', diff --git a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx index 8e5aa15aa9..44578560e6 100644 --- a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx +++ b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { FC, useState } from 'react'; import { Content, ContentHeader, @@ -25,32 +25,30 @@ import { Progress, HeaderLabel, useApi, + githubAuthApiRef, } from '@backstage/core'; import ClusterTable from '../ClusterTable/ClusterTable'; import { Button } from '@material-ui/core'; -import { useAsync, useLocalStorage } from 'react-use'; +import { useAsync } from 'react-use'; import { gitOpsApiRef, ListClusterStatusesResponse } from '../../api'; import { Alert } from '@material-ui/lab'; const ClusterList: FC<{}> = () => { - const [loginInfo] = useLocalStorage<{ - token: string; - username: string; - name: string; - }>('githubLoginDetails', { - token: '', - username: '', - name: 'Guest', - }); - const api = useApi(gitOpsApiRef); + const githubAuth = useApi(githubAuthApiRef); + const [githubUsername, setGithubUsername] = useState(String); const { loading, error, value } = useAsync( - () => { + async () => { + const accessToken = await githubAuth.getAccessToken(['repo', 'user']); + if (!githubUsername) { + const userInfo = await api.fetchUserInfo({ accessToken }); + setGithubUsername(userInfo.login); + } return api.listClusters({ - gitHubToken: loginInfo.token, - gitHubUser: loginInfo.username, + gitHubToken: accessToken, + gitHubUser: githubUsername, }); }, ); @@ -73,9 +71,6 @@ const ClusterList: FC<{}> = () => { Please make sure that you start GitOps-API backend on localhost port 3008 before using this plugin. - - If you're Guest, please login via GitHub first. - ); @@ -100,7 +95,7 @@ const ClusterList: FC<{}> = () => { return (
- +
{content}
diff --git a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx index d454278255..b2d62e8650 100644 --- a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx +++ b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx @@ -24,21 +24,16 @@ import { Progress, HeaderLabel, useApi, + githubAuthApiRef, } from '@backstage/core'; import { Link } from '@material-ui/core'; import { useParams } from 'react-router-dom'; -import { useLocalStorage } from 'react-use'; import { gitOpsApiRef, Status } from '../../api'; import { transformRunStatus } from '../ProfileCatalog'; const ClusterPage: FC<{}> = () => { const params = useParams() as { owner: string; repo: string }; - const [loginInfo] = useLocalStorage<{ - token: string; - username: string; - name: string; - }>('githubLoginDetails'); const [pollingLog, setPollingLog] = useState(true); const [runStatus, setRunStatus] = useState([]); @@ -46,6 +41,9 @@ const ClusterPage: FC<{}> = () => { const [showProgress, setShowProgress] = useState(true); const api = useApi(gitOpsApiRef); + const githubAuth = useApi(githubAuthApiRef); + const [githubAccessToken, setGithubAccessToken] = useState(String); + const [githubUsername, setGithubUsername] = useState(String); const columns = [ { field: 'status', title: 'Status' }, @@ -53,31 +51,43 @@ const ClusterPage: FC<{}> = () => { ]; useEffect(() => { - if (pollingLog) { - const interval = setInterval(async () => { - const resp = await api.fetchLog({ - gitHubToken: loginInfo.token, - gitHubUser: loginInfo.username, - targetOrg: params.owner, - targetRepo: params.repo, - }); + const fetchGithubUserInfo = async () => { + const accessToken = await githubAuth.getAccessToken(['repo', 'user']); + const userInfo = await api.fetchUserInfo({ accessToken }); + setGithubAccessToken(accessToken); + setGithubUsername(userInfo.login); + }; - setRunStatus(resp.result); - setRunLink(resp.link); - if (resp.status === 'completed') { - setPollingLog(false); - setShowProgress(false); - } - }, 10000); - return () => clearInterval(interval); + if (!githubAccessToken || !githubUsername) { + fetchGithubUserInfo(); + } else { + if (pollingLog) { + const interval = setInterval(async () => { + const resp = await api.fetchLog({ + gitHubToken: githubAccessToken, + gitHubUser: githubUsername, + targetOrg: params.owner, + targetRepo: params.repo, + }); + + setRunStatus(resp.result); + setRunLink(resp.link); + if (resp.status === 'completed') { + setPollingLog(false); + setShowProgress(false); + } + }, 10000); + return () => clearInterval(interval); + } } + return () => {}; - }, [pollingLog, api, loginInfo, params]); + }, [pollingLog, api, params, githubAuth, githubAccessToken, githubUsername]); return (
- +
diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index a4ff03af47..12cec30c80 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -20,16 +20,17 @@ import { useAsync } from 'react-use'; import { useLocation, useParams, useNavigate } from 'react-router-dom'; import { Grid } from '@material-ui/core'; -import { Header, Content } from '@backstage/core'; +import { Header, Content, ItemCard } from '@backstage/core'; import transformer, { addBaseUrl, rewriteDocLinks, addEventListener, + removeMkdocsHeader, + modifyCss, } from '../transformers'; import { docStorageURL } from '../../config'; import URLParser from '../urlParser'; -import { DocsCard } from './DocsCard'; const useFetch = (url: string) => { const state = useAsync(async () => { @@ -74,9 +75,17 @@ export const Reader = () => { componentId, path, }), - rewriteDocLinks({ - componentId, + rewriteDocLinks(), + modifyCss({ + cssTransforms: { + '.md-main__inner': [{ 'margin-top': '0' }], + '.md-sidebar': [{ top: '0' }, { width: '20rem' }], + '.md-typeset': [{ 'font-size': '1rem' }], + '.md-nav': [{ 'font-size': '1rem' }], + '.md-grid': [{ 'max-width': '80vw' }], + }, }), + removeMkdocsHeader(), ]); divElement.shadowRoot.innerHTML = ''; @@ -93,39 +102,37 @@ export const Reader = () => { return ( <> - {componentId ? ( -
- ) : ( - <> -
+
- - - - navigate('/docs/mkdocs')} - tags={['Developer Tool']} - title="MkDocs" - label="Read Docs" - description="MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. " - /> - - - navigate('/docs/backstage-microsite')} - tags={['Service']} - title="Backstage" - label="Read Docs" - description="Getting started guides, API Overview, documentation around how to Create a Plugin and more. " - /> - + + {componentId ? ( +
+ ) : ( + + + navigate('/docs/mkdocs')} + tags={['Developer Tool']} + title="MkDocs" + label="Read Docs" + description="MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. " + /> - - - )} + + navigate('/docs/backstage-microsite')} + tags={['Service']} + title="Backstage" + label="Read Docs" + description="Getting started guides, API Overview, documentation around how to Create a Plugin and more. " + /> + + + )} + ); }; diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts new file mode 100644 index 0000000000..b9cf9745ee --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { createTestShadowDom, FIXTURES, getSample } from '../../test-utils'; +import { addBaseUrl } from '../transformers'; + +const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com'; + +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, { + transformers: [ + addBaseUrl({ + docStorageURL: DOC_STORAGE_URL, + componentId: 'example-docs', + path: '', + }), + ], + }); + + 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', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + transformers: [ + addBaseUrl({ + docStorageURL: DOC_STORAGE_URL, + componentId: 'example-docs', + path: 'examplepath', + }), + ], + }); + + expect(getSample(shadowDom, 'img', 'src')).toEqual([ + 'https://example-host.storage.googleapis.com/example-docs/examplepath/img/win-py-install.png', + 'https://example-host.storage.googleapis.com/example-docs/examplepath/img/initial-layout.png', + ]); + expect(getSample(shadowDom, 'link', 'href')).toEqual([ + 'https://www.mkdocs.org/', + 'https://example-host.storage.googleapis.com/example-docs/examplepath/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/examplepath/assets/javascripts/vendor.d710d30a.min.js', + ]); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/addEventListener.test.ts b/plugins/techdocs/src/reader/transformers/addEventListener.test.ts new file mode 100644 index 0000000000..e87ab81ab5 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/addEventListener.test.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 { createTestShadowDom, FIXTURES } from '../../test-utils'; +import { addEventListener } from '../transformers'; + +describe('addEventListener', () => { + it('calls onClick when a link has been clicked', () => { + const fn = jest.fn(); + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + transformers: [ + addEventListener({ + onClick: fn, + }), + ], + }); + + shadowDom.querySelector('a')?.click(); + + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/index.test.ts b/plugins/techdocs/src/reader/transformers/index.test.ts new file mode 100644 index 0000000000..05ba13397f --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/index.test.ts @@ -0,0 +1,32 @@ +/* + * 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 transform, { Transformer } from '.'; + +describe('transform', () => { + it('calls the transformers', () => { + const fn = jest.fn(); + const mockTransformer = (): Transformer => (dom: Element) => { + fn(dom); + return dom; + }; + + transform('', [mockTransformer()]); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith(expect.any(Element)); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index 64c5527fe7..f49f496277 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -17,6 +17,8 @@ export * from './addBaseUrl'; export * from './rewriteDocLinks'; export * from './addEventListener'; +export * from './removeMkdocsHeader'; +export * from './modifyCss'; export type Transformer = (dom: Element) => Element; diff --git a/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx b/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx new file mode 100644 index 0000000000..fa808103dc --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx @@ -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 { createTestShadowDom } from '../../test-utils'; +import { modifyCss } from '../transformers'; + +describe('modifyCss', () => { + it('does not modify css', () => { + const shadowDom = createTestShadowDom( + `
`, + { + transformers: [], + }, + ); + + const { fontSize } = getComputedStyle( + shadowDom.querySelector('.md-typeset')!, + ); + + expect(fontSize).toBe('0.8em'); + }); + + it('does modify css', () => { + const shadowDom = createTestShadowDom( + `
`, + { + transformers: [ + modifyCss({ + cssTransforms: { + '.md-typeset': [{ 'font-size': '1em' }], + }, + }), + ], + }, + ); + + const { fontSize } = getComputedStyle( + shadowDom.querySelector('.md-typeset')!, + ); + + expect(fontSize).toBe('1em'); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/modifyCss.ts b/plugins/techdocs/src/reader/transformers/modifyCss.ts new file mode 100644 index 0000000000..5116baac45 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/modifyCss.ts @@ -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 type { Transformer } from './index'; + +type ModifyCssOptions = { + // Example: { '.md-container': { 'marginTop': '10px' }} + cssTransforms: { [key: string]: { [key: string]: string }[] }; +}; + +export const modifyCss = ({ cssTransforms }: ModifyCssOptions): Transformer => { + return dom => { + Object.entries(cssTransforms).forEach(([cssSelector, cssChanges]) => { + const elementsToChange = Array.from( + dom.querySelectorAll(cssSelector), + ); + if (elementsToChange.length < 1) return; + + cssChanges.forEach(changes => { + elementsToChange.forEach((element: HTMLElement) => { + Object.entries(changes).forEach(([cssProperty, cssValue]) => { + element.style.setProperty(cssProperty, cssValue); + }); + }); + }); + }); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.test.ts b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.test.ts new file mode 100644 index 0000000000..7e155ea317 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.test.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. + */ + +import { createTestShadowDom, FIXTURES } from '../../test-utils'; +import { removeMkdocsHeader } from '../transformers'; + +describe('removeMkdocsHeader', () => { + it('does not remove mkdocs header', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + transformers: [], + }); + + expect(shadowDom.querySelector('.md-header')).toBeTruthy(); + }); + + it('does remove mkdocs header', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + transformers: [removeMkdocsHeader()], + }); + + expect(shadowDom.querySelector('.md-header')).toBeFalsy(); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts new file mode 100644 index 0000000000..087574b2de --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts @@ -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 type { Transformer } from './index'; + +export const removeMkdocsHeader = (): Transformer => { + return dom => { + // Remove the header + dom.querySelector('.md-header')?.remove(); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts new file mode 100644 index 0000000000..83ca846142 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.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 { createTestShadowDom, getSample } from '../../test-utils'; +import { rewriteDocLinks } from '../transformers'; + +describe('rewriteDocLinks', () => { + it('should not do anything', () => { + const shadowDom = createTestShadowDom(` + Test + Test + Test + Test Sub Page + `); + + expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([ + 'http://example.org/', + '../example', + 'example-docs', + 'example-docs/example-page', + ]); + }); + + it('should transform a href with licalhost as baseUrl', () => { + const shadowDom = createTestShadowDom( + ` + Test + Test + Test + Test Sub Page + `, + { + transformers: [rewriteDocLinks()], + }, + ); + + expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([ + 'http://example.org/', + '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 3f80dbe4e9..7b8495b6b7 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -17,9 +17,7 @@ import URLParser from '../urlParser'; import type { Transformer } from './index'; -type AddBaseUrlOptions = {}; - -export const rewriteDocLinks = ({}: AddBaseUrlOptions): Transformer => { +export const rewriteDocLinks = (): Transformer => { return dom => { const updateDom = ( list: Array, diff --git a/plugins/techdocs/src/test-utils/fixtures/mkdocs-index.ts b/plugins/techdocs/src/test-utils/fixtures/mkdocs-index.ts new file mode 100644 index 0000000000..4fc0f6e8c4 --- /dev/null +++ b/plugins/techdocs/src/test-utils/fixtures/mkdocs-index.ts @@ -0,0 +1,1576 @@ +/* + * 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 ` + + + + + + + + + + + + + + + MkDocs + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+
+
+
+
+
+ +
+
+
+ + + +
+
+

+ MkDocs +

+

Project documentation with Markdown.

+
+

+ Overview +

+

+ MkDocs is a fast, simple and + downright gorgeous static site generator that's + geared towards building project documentation. Documentation + source files are written in Markdown, and configured with a + single YAML configuration file. Start by reading the + introduction below, then check the User Guide for more info. +

+

+ Host anywhere +

+

+ MkDocs builds completely static HTML sites that you can host on + GitHub pages, Amazon S3, or + anywhere else you + choose. +

+

+ Great themes available +

+

+ There's a stack of good looking + themes available for + MkDocs. Choose between the built in themes: + mkdocs and + readthedocs, select one of the 3rd party themes listed on the + MkDocs Themes + wiki page, or + build your own. +

+

+ Preview your site as you work +

+

+ The built-in dev-server allows you to preview your documentation + as you're writing it. It will even auto-reload and refresh your + browser whenever you save your changes. +

+

+ Easy to customize +

+

+ Get your project documentation looking just the way you want it + by customizing the + theme and/or + installing some plugins. +

+
+

+ Installation +

+

+ Install with a Package Manager +

+

+ If you have and use a package manager (such as + apt-get, + dnf, homebrew, + yum, + chocolatey, etc.) to + install packages on your system, then you may want to search for + a "MkDocs" package and, if a recent version is available, + install it with your package manager (check your system's + documentation for details). That's it, you're done! Skip down to + Getting Started. +

+

+ If your package manager does not have a recent "MkDocs" package, + you can still use your package manager to install "Python" and + "pip". Then you can use pip to + install MkDocs. +

+

+ Manual Installation +

+

+ In order to manually install MkDocs you'll need + Python installed on your + system, as well as the Python package manager, + pip. You can check if you have these already installed from the + command line: +

+
+
$ python --version
+Python 3.8.2
+$ pip --version
+pip 20.0.2 from /usr/local/lib/python3.8/site-packages/pip (python 3.8)
+
+
+ +

+ MkDocs supports Python versions 3.5, 3.6, 3.7, 3.8, and pypy3. +

+

+ Installing Python +

+

+ Install Python by + downloading an installer appropriate for your system from + python.org and + running it. +

+
+

Note

+

+ If you are installing Python on Windows, be sure to check the + box to have Python added to your PATH if the installer offers + such an option (it's normally off by default). +

+

+ Add Python to PATH +

+
+

+ Installing pip +

+

+ If you're using a recent version of Python, the Python package + manager, + pip, is most likely installed by default. However, you may need to + upgrade pip to the lasted version: +

+
+
pip install --upgrade pip
+
+
+ +

+ If you need to install + pip + for the first time, download + get-pip.py. + Then run the following command to install it: +

+
+
python get-pip.py
+
+
+ +

+ Installing MkDocs +

+

Install the mkdocs package using pip:

+
+
pip install mkdocs
+
+
+ +

+ You should now have the mkdocs command installed on + your system. Run mkdocs --version to check that + everything worked okay. +

+
+
$ mkdocs --version
+mkdocs, version 0.15.3
+
+
+ +
+

Note

+

+ If you would like manpages installed for MkDocs, the + click-man + tool can generate and install them for you. Simply run the + following two commands: +

+
+ + + + +
+
+
+1
+2
+
+
+
+
pip install click-man
+click-man --target path/to/man/pages mkdocs
+
+
+
+ +

+ See the + click-man documentation + for an explanation of why manpages are not automatically + generated and installed by pip. +

+
+
+

Note

+

+ If you are using Windows, some of the above commands may not + work out-of-the-box. +

+

+ A quick solution may be to preface every Python command with + python -m like this: +

+ + + + + +
+
+
+1
+2
+
+
+
+
python -m pip install mkdocs
+python -m mkdocs
+
+
+
+ +

+ For a more permanent solution, you may need to edit your + PATH environment variable to include the + Scripts directory of your Python installation. + Recent versions of Python include a script to do this for you. + Navigate to your Python installation directory (for example + C:\Python38\), open the Tools, then + Scripts folder, and run the + win_add2path.py file by double clicking on it. + Alternatively, you can + download + the script and run it (python win_add2path.py). +

+
+
+

+ Getting Started +

+

Getting started is super easy.

+
+
mkdocs new my-project
+cd my-project
+
+
+ +

+ Take a moment to review the initial project that has been + created for you. +

+

+ The initial MkDocs layout +

+

+ There's a single configuration file named + mkdocs.yml, and a folder named + docs that will contain your documentation source + files. Right now the docs folder just contains a + single documentation page, named index.md. +

+

+ MkDocs comes with a built-in dev-server that lets you preview + your documentation as you work on it. Make sure you're in the + same directory as the mkdocs.yml configuration + file, and then start the server by running the + mkdocs serve command: +

+
+
$ mkdocs serve
+INFO    -  Building documentation...
+INFO    -  Cleaning site directory
+[I 160402 15:50:43 server:271] Serving on http://127.0.0.1:8000
+[I 160402 15:50:43 handlers:58] Start watching changes
+[I 160402 15:50:43 handlers:60] Start detecting changes
+
+
+ +

+ Open up http://127.0.0.1:8000/ in your browser, and + you'll see the default home page being displayed: +

+

+ The MkDocs live server +

+

+ The dev-server also supports auto-reloading, and will rebuild + your documentation whenever anything in the configuration file, + documentation directory, or theme directory changes. +

+

+ Open the docs/index.md document in your text editor + of choice, change the initial heading to MkLorum, + and save your changes. Your browser will auto-reload and you + should see your updated documentation immediately. +

+

+ Now try editing the configuration file: mkdocs.yml. + Change the + site_name + setting to MkLorum and save the file. +

+
+
site_name: MkLorum
+
+
+ +

+ Your browser should immediately reload, and you'll see your new + site name take effect. +

+

The site_name setting

+

+ Adding pages +

+

Now add a second page to your documentation:

+
+
curl 'https://jaspervdj.be/lorem-markdownum/markdown.txt' > docs/about.md
+
+
+ +

+ As our documentation site will include some navigation headers, + you may want to edit the configuration file and add some + information about the order, title, and nesting of each page in + the navigation header by adding a + nav + setting: +

+
+
site_name: MkLorum
+nav:
+    - Home: index.md
+    - About: about.md
+
+
+ +

+ Save your changes and you'll now see a navigation bar with + Home and About items on the left as + well as Search, Previous, and + Next items on the right. +

+

Screenshot

+

+ Try the menu items and navigate back and forth between pages. + Then click on Search. A search dialog will appear, + allowing you to search for any text on any page. Notice that the + search results include every occurrence of the search term on + the site and links directly to the section of the page in which + the search term appears. You get all of that with no effort or + configuration on your part! +

+

Screenshot

+

+ Theming our documentation +

+

+ Now change the configuration file to alter how the documentation + is displayed by changing the theme. Edit the + mkdocs.yml file and add a + theme + setting: +

+
+
site_name: MkLorum
+nav:
+    - Home: index.md
+    - About: about.md
+theme: readthedocs
+
+
+ +

+ Save your changes, and you'll see the ReadTheDocs theme being + used. +

+

Screenshot

+

+ Changing the Favicon Icon +

+

+ By default, MkDocs uses the + MkDocs favicon icon. To use a + different icon, create an img subdirectory in your + docs_dir and copy your custom + favicon.ico file to that directory. MkDocs will + automatically detect and use that file as your favicon icon. +

+

+ Building the site +

+

+ That's looking good. You're ready to deploy the first pass of + your MkLorum documentation. First build the + documentation: +

+
+
mkdocs build
+
+
+ +

+ This will create a new directory, named site. Take + a look inside the directory: +

+
+
$ ls site
+about  fonts  index.html  license  search.html
+css    img    js          mkdocs   sitemap.xml
+
+
+ +

+ Notice that your source documentation has been output as two + HTML files named index.html and + about/index.html. You also have various other media + that's been copied into the site directory as part + of the documentation theme. You even have a + sitemap.xml file and + mkdocs/search_index.json. +

+

+ If you're using source code control such as git you + probably don't want to check your documentation builds into the + repository. Add a line containing site/ to your + .gitignore file. +

+
+
echo "site/" >> .gitignore
+
+
+ +

+ If you're using another source code control tool you'll want to + check its documentation on how to ignore specific directories. +

+

+ After some time, files may be removed from the documentation but + they will still reside in the site directory. To + remove those stale files, just run mkdocs with the + --clean switch. +

+
+
mkdocs build --clean
+
+
+ +

+ Other Commands and Options +

+

+ There are various other commands and options available. For a + complete list of commands, use the --help flag: +

+
+
mkdocs --help
+
+
+ +

+ To view a list of options available on a given command, use the + --help flag with that command. For example, to get + a list of all options available for the + build command run the following: +

+
+
mkdocs build --help
+
+
+ +

+ Deploying +

+

+ The documentation site that you just built only uses static + files so you'll be able to host it from pretty much anywhere. + GitHub project pages + and + Amazon S3 + may be good hosting options, depending upon your needs. Upload + the contents of the entire site directory to + wherever you're hosting your website from and you're done. For + specific instructions on a number of common hosts, see the + Deploying your Docs + page. +

+

+ Getting help +

+

+ To get help with MkDocs, please use the + discussion group, + GitHub issues + or the MkDocs IRC channel #mkdocs on freenode. +

+ + + + + + + + + + + + + + + +`; diff --git a/plugins/techdocs/src/test-utils/index.ts b/plugins/techdocs/src/test-utils/index.ts new file mode 100644 index 0000000000..f9ced5f930 --- /dev/null +++ b/plugins/techdocs/src/test-utils/index.ts @@ -0,0 +1,63 @@ +/* + * 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 FIXTURE_STANDARD_PAGE from './fixtures/mkdocs-index'; +import transformer from '../reader/transformers'; +import type { Transformer } from '../reader/transformers'; + +export const FIXTURES = { + FIXTURE_STANDARD_PAGE, +}; + +export type CreateTestShadowDomOptions = { + transformers: Transformer[]; +}; + +export const createTestShadowDom = ( + fixture: string, + opts: CreateTestShadowDomOptions = { transformers: [] }, +): ShadowRoot => { + const divElement = document.createElement('div'); + divElement.attachShadow({ mode: 'open' }); + document.body.appendChild(divElement); + + const domParser = new DOMParser().parseFromString(fixture, 'text/html'); + divElement.shadowRoot?.appendChild(domParser.documentElement); + + if (opts.transformers) { + transformer(divElement.shadowRoot!.children[0], opts.transformers); + } + + return divElement.shadowRoot!; +}; + +export const getSample = ( + shadowDom: ShadowRoot, + elementName: string, + elementAttribute: string, + sampleSize = 2, +) => { + const rootElement = shadowDom.children[0]; + + return Array.from(rootElement.getElementsByTagName(elementName)) + .filter(elem => { + return elem.hasAttribute(elementAttribute); + }) + .slice(0, sampleSize) + .map(elem => { + return elem.getAttribute(elementAttribute); + }); +}; diff --git a/yarn.lock b/yarn.lock index 2a86168c2c..c790e45998 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14191,6 +14191,13 @@ passport-github2@^0.1.12: dependencies: passport-oauth2 "1.x.x" +passport-gitlab2@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/passport-gitlab2/-/passport-gitlab2-5.0.0.tgz#ea37e5285321c026a02671e87469cac28cce9b69" + integrity sha512-cXQMgM6JQx9wHVh7JLH30D8fplfwjsDwRz+zS0pqC8JS+4bNmc1J04NGp5g2M4yfwylH9kQRrMN98GxMw7q7cg== + dependencies: + passport-oauth2 "^1.4.0" + passport-google-oauth20@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz#0d241b2d21ebd3dc7f2b60669ec4d587e3a674ef" @@ -14207,7 +14214,7 @@ passport-oauth1@1.x.x: passport-strategy "1.x.x" utils-merge "1.x.x" -passport-oauth2@1.x.x, passport-oauth2@^1.5.0: +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" integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ== @@ -15689,20 +15696,15 @@ react-router-dom@6.0.0-alpha.5, react-router-dom@^6.0.0-alpha.5: history "5.0.0-beta.9" prop-types "^15.7.2" -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== +react-router@6.0.0-alpha.5, react-router@^6.0.0-alpha.5: + version "6.0.0-alpha.5" + resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-alpha.5.tgz#c98805e50dc0e64787aa8aa4fa6753b435f2496b" + integrity sha512-cDj70bTUAgcfx6b5Fx1+wVlBSDVZGo8N+GUDk/yNFDCyGLfAsFlRpS3BhQqx8c49w2cCW+OrXxFhB4cbLZxWJw== 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" + history "5.0.0-beta.9" + prop-types "^15.7.2" -react-router@5.2.0, react-router@^5.2.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== @@ -15718,14 +15720,6 @@ react-router@5.2.0, react-router@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" -react-router@6.0.0-alpha.5, react-router@^6.0.0-alpha.5: - version "6.0.0-alpha.5" - resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-alpha.5.tgz#c98805e50dc0e64787aa8aa4fa6753b435f2496b" - integrity sha512-cDj70bTUAgcfx6b5Fx1+wVlBSDVZGo8N+GUDk/yNFDCyGLfAsFlRpS3BhQqx8c49w2cCW+OrXxFhB4cbLZxWJw== - dependencies: - history "5.0.0-beta.9" - prop-types "^15.7.2" - react-side-effect@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.0.tgz#1ce4a8b4445168c487ed24dab886421f74d380d3"