diff --git a/.changeset/serious-horses-juggle.md b/.changeset/serious-horses-juggle.md
new file mode 100644
index 0000000000..13be1d9715
--- /dev/null
+++ b/.changeset/serious-horses-juggle.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-linguist': minor
+'@backstage/plugin-linguist-backend': minor
+'@backstage/plugin-linguist-common': minor
+---
+
+Introduced the Linguist plugin, checkout the plugin's `README.md` for more details!
diff --git a/packages/app/package.json b/packages/app/package.json
index 80ee11ea15..8c26254268 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -42,6 +42,8 @@
"@backstage/plugin-kubernetes": "workspace:^",
"@backstage/plugin-lighthouse": "workspace:^",
"@backstage/plugin-microsoft-calendar": "workspace:^",
+ "@backstage/plugin-linguist": "workspace:^",
+ "@backstage/plugin-linguist-common": "workspace:^",
"@backstage/plugin-newrelic": "workspace:^",
"@backstage/plugin-newrelic-dashboard": "workspace:^",
"@backstage/plugin-org": "workspace:^",
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index 3ab0e83238..9118347414 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -157,6 +157,10 @@ import {
LightBox,
} from '@backstage/plugin-techdocs-module-addons-contrib';
import { EntityCostInsightsContent } from '@backstage/plugin-cost-insights';
+import {
+ isLinguistAvailable,
+ EntityLinguistCard,
+} from '@backstage/plugin-linguist';
const customEntityFilterKind = ['Component', 'API', 'System'];
@@ -406,6 +410,14 @@ const overviewContent = (
+
+
+
+
+
+
+
+
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 3678c1f9ae..776aa18862 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -50,6 +50,7 @@
"@backstage/plugin-kafka-backend": "workspace:^",
"@backstage/plugin-kubernetes-backend": "workspace:^",
"@backstage/plugin-lighthouse-backend": "workspace:^",
+ "@backstage/plugin-linguist-backend": "workspace:^",
"@backstage/plugin-permission-backend": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index b68b162e52..eb2befca65 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -63,6 +63,7 @@ import permission from './plugins/permission';
import playlist from './plugins/playlist';
import adr from './plugins/adr';
import lighthouse from './plugins/lighthouse';
+import linguist from './plugins/linguist';
import { PluginEnvironment } from './types';
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
@@ -154,6 +155,7 @@ async function main() {
const eventBasedEntityProviders = await catalogEventBasedProviders(
catalogEnv,
);
+ const linguistEnv = useHotMemoize(module, () => createEnv('linguist'));
const apiRouter = Router();
apiRouter.use(
@@ -180,6 +182,7 @@ async function main() {
apiRouter.use('/playlist', await playlist(playlistEnv));
apiRouter.use('/explore', await explore(exploreEnv));
apiRouter.use('/adr', await adr(adrEnv));
+ apiRouter.use('/linguist', await linguist(linguistEnv));
apiRouter.use(notFoundHandler());
await lighthouse(lighthouseEnv);
diff --git a/packages/backend/src/plugins/linguist.ts b/packages/backend/src/plugins/linguist.ts
new file mode 100644
index 0000000000..d8e56f658b
--- /dev/null
+++ b/packages/backend/src/plugins/linguist.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 { TaskScheduleDefinition } from '@backstage/backend-tasks';
+import { createRouter } from '@backstage/plugin-linguist-backend';
+import { Router } from 'express';
+import type { PluginEnvironment } from '../types';
+
+export default async function createPlugin(
+ env: PluginEnvironment,
+): Promise {
+ const schedule: TaskScheduleDefinition = {
+ frequency: { minutes: 2 },
+ timeout: { minutes: 15 },
+ initialDelay: { seconds: 15 },
+ };
+
+ return createRouter({
+ logger: env.logger,
+ config: env.config,
+ reader: env.reader,
+ discovery: env.discovery,
+ database: env.database,
+ scheduler: env.scheduler,
+ schedule: schedule,
+ });
+}
diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml
index c0022762b4..a3a3780c84 100644
--- a/packages/catalog-model/examples/components/artist-lookup-component.yaml
+++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml
@@ -28,6 +28,8 @@ metadata:
- url: https://example.com/alert
title: Alerts
icon: alert
+ annotations:
+ backstage.io/linguist: 'https://github.com/backstage/backstage/tree/master/plugins/playlist'
spec:
type: service
lifecycle: experimental
diff --git a/plugins/linguist-backend/.eslintrc.js b/plugins/linguist-backend/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/linguist-backend/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/linguist-backend/README.md b/plugins/linguist-backend/README.md
new file mode 100644
index 0000000000..d3c39a5fe7
--- /dev/null
+++ b/plugins/linguist-backend/README.md
@@ -0,0 +1,94 @@
+# Linguist Backend
+
+Welcome to the Linguist backend plugin! This plugin provides data for the Linguist frontend features.
+
+## Setup
+
+The following sections will help you get the Linguist Backend plugin setup and running.
+
+### Up and Running
+
+Here's how to get the backend up and running:
+
+1. First we need to add the `@backstage/plugin-linguist-backend` package to your backend:
+
+ ```sh
+ # From the Backstage root directory
+ cd packages/backend
+ yarn add @backstage/plugin-linguist-backend
+ ```
+
+2. Then we will create a new file named `packages/backend/src/plugins/linguist.ts`, and add the
+ following to it:
+
+ ```ts
+ import { TaskScheduleDefinition } from '@backstage/backend-tasks';
+ import { createRouter } from '@backstage/plugin-linguist-backend';
+ import { Router } from 'express';
+ import type { PluginEnvironment } from '../types';
+
+ export default async function createPlugin(
+ env: PluginEnvironment,
+ ): Promise {
+ const schedule: TaskScheduleDefinition = {
+ frequency: { hours: 24 },
+ timeout: { minutes: 30 },
+ initialDelay: { seconds: 90 },
+ };
+
+ return createRouter({
+ logger: env.logger,
+ config: env.config,
+ reader: env.reader,
+ discovery: env.discovery,
+ database: env.database,
+ scheduler: env.scheduler,
+ schedule: schedule,
+ });
+ }
+ ```
+
+3. Next we wire this into the overall backend router, edit `packages/backend/src/index.ts`:
+
+ ```ts
+ import linguist from './plugins/linguist';
+ // ...
+ async function main() {
+ // ...
+ // Add this line under the other lines that follow the useHotMemoize pattern
+ const linguistEnv = useHotMemoize(module, () => createEnv('linguist'));
+ // ...
+ // Insert this line under the other lines that add their routers to apiRouter in the same way
+ apiRouter.use('/linguist', await linguist(linguistEnv));
+ ```
+
+4. Now run `yarn start-backend` from the repo root
+5. Finally open `http://localhost:7007/api/linguist/health` in a browser and it should return `{"status":"ok"}`
+
+## Scheduling
+
+The Linguist backend can be configured to generate the language breakdown for all the entities with the linguist annotation. This is done by passing a `TaskScheduleDefinition` to the `createRouter` function like in Step 2 of the [Up and Running](#up-and-running) documentation.
+
+Here's another example of the `TaskScheduleDefinition` that will run the language breakdown processing every 12 hours:
+
+```ts
+const schedule: TaskScheduleDefinition = {
+ frequency: { hours: 12 },
+ timeout: { minutes: 15 },
+ initialDelay: { seconds: 10 },
+};
+```
+
+The default setup will only generate the language breakdown for entities with the linguist annotation that have not been generated yet. If you want this process to also refresh the data you can do so by adding the following configuration to your `app-config.yaml`:
+
+```yaml
+linguist:
+ age: 15 # Days
+```
+
+With the configuration setup like this if the language breakdown is older than 15 days it will get regenerated. It's recommended that if you choose to use this configuration to set it to a large value - 30, 90, or 180 - as this data generally does not change drastically.
+
+## Links
+
+- [Frontend part of the plugin](../linguist/README.md)
+- [The Backstage homepage](https://backstage.io)
diff --git a/plugins/linguist-backend/api-report.md b/plugins/linguist-backend/api-report.md
new file mode 100644
index 0000000000..26656416df
--- /dev/null
+++ b/plugins/linguist-backend/api-report.md
@@ -0,0 +1,86 @@
+## API Report File for "@backstage/plugin-linguist-backend"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { Config } from '@backstage/config';
+import { EntitiesOverview } from '@backstage/plugin-linguist-common';
+import { EntityResults } from '@backstage/plugin-linguist-common';
+import express from 'express';
+import { Knex } from 'knex';
+import { Languages } from '@backstage/plugin-linguist-common';
+import { Logger } from 'winston';
+import { PluginDatabaseManager } from '@backstage/backend-common';
+import { PluginEndpointDiscovery } from '@backstage/backend-common';
+import { PluginTaskScheduler } from '@backstage/backend-tasks';
+import { ProcessedEntity } from '@backstage/plugin-linguist-common';
+import { TaskScheduleDefinition } from '@backstage/backend-tasks';
+import { UrlReader } from '@backstage/backend-common';
+
+// @public (undocumented)
+export function createRouter(options: RouterOptions): Promise;
+
+// @public (undocumented)
+export class LinguistBackendApi {
+ constructor(
+ config: Config,
+ logger: Logger,
+ store: LinguistBackendStore,
+ urlReader: UrlReader,
+ discovery: PluginEndpointDiscovery,
+ );
+ // (undocumented)
+ getEntitiesOverview(): Promise;
+ // (undocumented)
+ getEntityLanguages(entityRef: string): Promise;
+ // (undocumented)
+ processEntities(): Promise;
+ // (undocumented)
+ processEntity(entityRef: string, url: string): Promise;
+}
+
+// @public (undocumented)
+export class LinguistBackendDatabase implements LinguistBackendStore {
+ constructor(db: Knex);
+ // (undocumented)
+ static create(knex: Knex): Promise;
+ // (undocumented)
+ getEntityResults(entityRef: string): Promise;
+ // (undocumented)
+ getProcessedEntities(): Promise;
+ // (undocumented)
+ insertEntityResults(entityLanguages: EntityResults): Promise;
+}
+
+// @public (undocumented)
+export interface LinguistBackendStore {
+ // (undocumented)
+ getEntityResults(entityRef: string): Promise;
+ // (undocumented)
+ getProcessedEntities(): Promise;
+ // (undocumented)
+ insertEntityResults(entityLanguages: EntityResults): Promise;
+}
+
+// @public (undocumented)
+export interface RouterOptions {
+ // (undocumented)
+ config: Config;
+ // (undocumented)
+ database: PluginDatabaseManager;
+ // (undocumented)
+ discovery: PluginEndpointDiscovery;
+ // (undocumented)
+ linguistBackendApi?: LinguistBackendApi;
+ // (undocumented)
+ logger: Logger;
+ // (undocumented)
+ reader: UrlReader;
+ // (undocumented)
+ schedule?: TaskScheduleDefinition;
+ // (undocumented)
+ scheduler?: PluginTaskScheduler;
+}
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/linguist-backend/config.d.ts b/plugins/linguist-backend/config.d.ts
new file mode 100644
index 0000000000..8e0133c90b
--- /dev/null
+++ b/plugins/linguist-backend/config.d.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 interface Config {
+ /** Configuration options for the linguist-backend plugin */
+ linguist?: {
+ /**
+ * The age of an entity's languages before a refresh occurs,
+ * an age of 30 would mean the languages would be regenerated after 30 days.
+ * The default is 0 meaning languages won't be refreshed once generated.
+ */
+ age: number;
+ };
+}
diff --git a/plugins/linguist-backend/migrations/20221115_init.js b/plugins/linguist-backend/migrations/20221115_init.js
new file mode 100644
index 0000000000..9eb0a03a90
--- /dev/null
+++ b/plugins/linguist-backend/migrations/20221115_init.js
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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.
+ */
+
+/**
+ * @param {import('knex').Knex} knex
+ */
+exports.up = async function up(knex) {
+ // Note for the reader: the knex increments types automatically make it a
+ // primary column, whether you like it or not. That's why the id column is
+ // not marked as primary as one might have expected; it's only used for
+ // lookups by ID. Because, SQLite and MySQL don't return RETURNING on
+ // inserts ... so we want a manually generated key for lookups (an uuid),
+ // and also an index for ordering guarantees :)
+ await knex.schema.createTable('entity_result', table => {
+ table.comment('Table containing results from running Linguist');
+ table
+ .bigIncrements('index')
+ .notNullable()
+ .comment('An insert counter to ensure ordering');
+ table.uuid('id').notNullable().comment('The ID of the Linguist result');
+ table
+ .text('entity_ref')
+ .unique()
+ .notNullable()
+ .comment('The entity ref that this Linguist result applies to');
+ table
+ .text('languages')
+ .notNullable()
+ .comment('The results json as a string');
+ table
+ .dateTime('created_at')
+ .defaultTo(knex.fn.now())
+ .notNullable()
+ .comment('The timestamp when this entry was created');
+ table
+ .dateTime('processed_date')
+ .defaultTo(knex.fn.now())
+ .notNullable()
+ .comment('The timestamp when this entity was processed');
+ table.index('index', 'entity_result_index_idx');
+ table.index('entity_ref', 'entity_result_entity_ref_idx');
+ });
+};
+
+/**
+ * @param {import('knex').Knex} knex
+ */
+exports.down = async function down(knex) {
+ await knex.schema.alterTable('entity_result', table => {
+ table.dropIndex([], 'entity_result_index_idx');
+ table.dropIndex([], 'entity_result_entity_ref_idx');
+ });
+ await knex.schema.dropTable('entity_result');
+};
diff --git a/plugins/linguist-backend/package.json b/plugins/linguist-backend/package.json
new file mode 100644
index 0000000000..b79a97decd
--- /dev/null
+++ b/plugins/linguist-backend/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "@backstage/plugin-linguist-backend",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.cjs.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "backend-plugin"
+ },
+ "scripts": {
+ "start": "backstage-cli package start",
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/backend-common": "workspace:^",
+ "@backstage/backend-tasks": "workspace:^",
+ "@backstage/catalog-client": "workspace:^",
+ "@backstage/catalog-model": "workspace:^",
+ "@backstage/config": "workspace:^",
+ "@backstage/errors": "workspace:^",
+ "@backstage/plugin-auth-node": "workspace:^",
+ "@backstage/plugin-linguist-common": "workspace:^",
+ "@types/express": "*",
+ "express": "^4.18.1",
+ "express-promise-router": "^4.1.0",
+ "fs-extra": "^10.0.0",
+ "knex": "^2.0.0",
+ "linguist-js": "^2.5.3",
+ "luxon": "^2.0.2",
+ "node-fetch": "^2.6.7",
+ "uuid": "^8.3.2",
+ "winston": "^3.2.1",
+ "yn": "^4.0.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^",
+ "@types/supertest": "^2.0.8",
+ "msw": "^0.49.0",
+ "supertest": "^6.2.4"
+ },
+ "files": [
+ "dist",
+ "config.d.ts",
+ "migrations/**/*.{js,d.ts}"
+ ],
+ "configSchema": "config.d.ts"
+}
diff --git a/plugins/linguist-backend/src/api/LinguistBackendApi.ts b/plugins/linguist-backend/src/api/LinguistBackendApi.ts
new file mode 100644
index 0000000000..fdabea6d6c
--- /dev/null
+++ b/plugins/linguist-backend/src/api/LinguistBackendApi.ts
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 {
+ EntitiesOverview,
+ EntityResults,
+ Language,
+ Languages,
+} from '@backstage/plugin-linguist-common';
+import {
+ CATALOG_FILTER_EXISTS,
+ CatalogClient,
+ GetEntitiesRequest,
+} from '@backstage/catalog-client';
+import { PluginEndpointDiscovery, UrlReader } from '@backstage/backend-common';
+
+import { Config } from '@backstage/config';
+import { DateTime } from 'luxon';
+import { LINGUIST_ANNOTATION } from '@backstage/plugin-linguist-common';
+import { LinguistBackendStore } from '../db';
+import { Logger } from 'winston';
+import fs from 'fs-extra';
+import linguist from 'linguist-js';
+import { stringifyEntityRef } from '@backstage/catalog-model';
+import { assertError } from '@backstage/errors';
+
+/** @public */
+export class LinguistBackendApi {
+ public constructor(
+ private readonly config: Config,
+ private readonly logger: Logger,
+ private readonly store: LinguistBackendStore,
+ private readonly urlReader: UrlReader,
+ private readonly discovery: PluginEndpointDiscovery,
+ ) {}
+
+ public async getEntityLanguages(entityRef: string): Promise {
+ this.logger?.debug(`Getting languages for entity "${entityRef}"`);
+
+ return this.store.getEntityResults(entityRef);
+ }
+
+ public async processEntity(entityRef: string, url: string): Promise {
+ this.logger?.info(
+ `Processing languages for entity ${entityRef} from ${url}`,
+ );
+
+ const readTreeResponse = await this.urlReader.readTree(url);
+ const dir = await readTreeResponse.dir();
+
+ const results = await linguist(dir);
+
+ try {
+ const totalBytes = results.languages.bytes;
+ const langResults = results.languages.results;
+
+ const breakdown: Language[] = [];
+ for (const key in langResults) {
+ if (Object.prototype.hasOwnProperty.call(langResults, key)) {
+ const lang: Language = {
+ name: key,
+ percentage: +((langResults[key].bytes / totalBytes) * 100).toFixed(
+ 2,
+ ),
+ bytes: langResults[key].bytes,
+ type: langResults[key].type,
+ color: langResults[key].color,
+ };
+ breakdown.push(lang);
+ }
+ }
+
+ const languages: Languages = {
+ languageCount: results.languages.count,
+ totalBytes: totalBytes,
+ processedDate: new Date().toISOString(),
+ breakdown: breakdown,
+ };
+
+ const entityResults: EntityResults = {
+ entityRef: entityRef,
+ results: languages,
+ };
+
+ return await this.store.insertEntityResults(entityResults);
+ } finally {
+ this.logger?.info(`Cleaning up files from ${dir}`);
+ await fs.remove(dir);
+ }
+ }
+
+ public async processEntities() {
+ this.logger?.info('Processing applicable entities through Linguist');
+
+ const eo = await this.getEntitiesOverview();
+ this.logger?.info(
+ `Entities overview: Entity: ${eo.entityCount}, Processed: ${eo.processedCount}, Pending: ${eo.pendingCount}, Stale ${eo.staleCount}`,
+ );
+
+ const entities = eo.filteredEntities;
+ for (const key in entities) {
+ if (Object.prototype.hasOwnProperty.call(entities, key)) {
+ const entityRef = stringifyEntityRef(entities[key]);
+
+ const url =
+ entities[key].metadata.annotations?.[LINGUIST_ANNOTATION] ?? '';
+
+ try {
+ await this.processEntity(entityRef, url);
+ } catch (e) {
+ assertError(e);
+ this.logger.error(
+ `Unable to process "${entityRef}" using "${url}", ${e.message}`,
+ );
+ }
+ }
+ }
+ }
+
+ public async getEntitiesOverview(): Promise {
+ this.logger?.debug('Getting pending entities');
+ const age = this.config.getOptionalNumber('linguist.age') ?? 0;
+ const catalogApi = new CatalogClient({ discoveryApi: this.discovery });
+ const request: GetEntitiesRequest = {
+ filter: {
+ kind: ['API', 'Component', 'Template'],
+ [`metadata.annotations.${LINGUIST_ANNOTATION}`]: CATALOG_FILTER_EXISTS,
+ },
+ fields: ['kind', 'metadata'],
+ };
+
+ const response = await catalogApi.getEntities(request);
+ const entities = response.items;
+
+ const processedEntities = await this.store.getProcessedEntities();
+
+ const staleEntities = processedEntities.filter(pe => {
+ if (age === 0) return false;
+ const staleDate = DateTime.now().minus({ days: age });
+ return DateTime.fromJSDate(pe.processed_date) >= staleDate;
+ });
+
+ const filteredEntities = entities.filter(en => {
+ return processedEntities.every(pe => {
+ const entityRef = stringifyEntityRef(en);
+ return pe.entityRef !== entityRef;
+ });
+ });
+
+ const entitiesOverview: EntitiesOverview = {
+ entityCount: entities.length,
+ processedCount: processedEntities.length,
+ staleCount: staleEntities.length,
+ pendingCount: filteredEntities.length,
+ filteredEntities: filteredEntities,
+ };
+
+ return entitiesOverview;
+ }
+}
diff --git a/plugins/linguist-backend/src/api/index.ts b/plugins/linguist-backend/src/api/index.ts
new file mode 100644
index 0000000000..a88fa051d4
--- /dev/null
+++ b/plugins/linguist-backend/src/api/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 { LinguistBackendApi } from './LinguistBackendApi';
diff --git a/plugins/linguist-backend/src/db/LinguistBackendDatabase.ts b/plugins/linguist-backend/src/db/LinguistBackendDatabase.ts
new file mode 100644
index 0000000000..8faa5c3ff0
--- /dev/null
+++ b/plugins/linguist-backend/src/db/LinguistBackendDatabase.ts
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 { resolvePackagePath } from '@backstage/backend-common';
+import { Knex } from 'knex';
+import { v4 as uuid } from 'uuid';
+import {
+ EntityResults,
+ Languages,
+ ProcessedEntity,
+} from '@backstage/plugin-linguist-common';
+
+export type RawDbEntityResultRow = {
+ id: string;
+ entity_ref: string;
+ languages: string;
+ processed_date: Date;
+};
+
+/** @public */
+export interface LinguistBackendStore {
+ insertEntityResults(entityLanguages: EntityResults): Promise;
+ getEntityResults(entityRef: string): Promise;
+ getProcessedEntities(): Promise;
+}
+
+const migrationsDir = resolvePackagePath(
+ '@backstage/plugin-linguist-backend',
+ 'migrations',
+);
+
+/** @public */
+export class LinguistBackendDatabase implements LinguistBackendStore {
+ static async create(knex: Knex): Promise {
+ await knex.migrate.latest({
+ directory: migrationsDir,
+ });
+ return new LinguistBackendDatabase(knex);
+ }
+
+ constructor(private readonly db: Knex) {}
+
+ async insertEntityResults(entityLanguages: EntityResults): Promise {
+ const entityLanguageId = uuid();
+ const entityRef = entityLanguages.entityRef;
+
+ const [result] = await this.db('entity_result')
+ .insert({
+ id: entityLanguageId,
+ entity_ref: entityRef,
+ languages: JSON.stringify(entityLanguages.results),
+ processed_date: new Date(entityLanguages.results.processedDate),
+ })
+ .onConflict('entity_ref')
+ .merge(['languages', 'processed_date'])
+ .returning('id');
+
+ return result.id;
+ }
+
+ async getEntityResults(entityRef: string): Promise {
+ const entityResults = await this.db('entity_result')
+ .where({ entity_ref: entityRef })
+ .first();
+
+ if (!entityResults) {
+ const emptyResults: Languages = {
+ languageCount: 0,
+ totalBytes: 0,
+ processedDate: 'undefined',
+ breakdown: [],
+ };
+ return emptyResults;
+ }
+
+ try {
+ return JSON.parse(entityResults.languages);
+ } catch (error) {
+ throw new Error(`Failed to parse languages for '${entityRef}', ${error}`);
+ }
+ }
+
+ async getProcessedEntities(): Promise {
+ const entityResults = await this.db(
+ 'entity_result',
+ ).select('entity_ref', 'processed_date');
+
+ return entityResults;
+ }
+}
diff --git a/plugins/linguist-backend/src/db/index.ts b/plugins/linguist-backend/src/db/index.ts
new file mode 100644
index 0000000000..c8ec15da68
--- /dev/null
+++ b/plugins/linguist-backend/src/db/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 { LinguistBackendDatabase } from './LinguistBackendDatabase';
+export type { LinguistBackendStore } from './LinguistBackendDatabase';
diff --git a/plugins/linguist-backend/src/index.ts b/plugins/linguist-backend/src/index.ts
new file mode 100644
index 0000000000..da1e0ffb00
--- /dev/null
+++ b/plugins/linguist-backend/src/index.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 './service/router';
+export { LinguistBackendApi } from './api';
+export { LinguistBackendDatabase } from './db';
+export type { LinguistBackendStore } from './db';
diff --git a/plugins/linguist-backend/src/run.ts b/plugins/linguist-backend/src/run.ts
new file mode 100644
index 0000000000..d945aa13f0
--- /dev/null
+++ b/plugins/linguist-backend/src/run.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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) : 7007;
+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/linguist-backend/src/service/router.test.ts b/plugins/linguist-backend/src/service/router.test.ts
new file mode 100644
index 0000000000..0e20987a43
--- /dev/null
+++ b/plugins/linguist-backend/src/service/router.test.ts
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 {
+ DatabaseManager,
+ getVoidLogger,
+ PluginDatabaseManager,
+ PluginEndpointDiscovery,
+ UrlReaders,
+} from '@backstage/backend-common';
+import express from 'express';
+import request from 'supertest';
+import { ConfigReader } from '@backstage/config';
+import { createRouter } from './router';
+import { LinguistBackendApi } from '../api';
+
+function createDatabase(): PluginDatabaseManager {
+ return DatabaseManager.fromConfig(
+ new ConfigReader({
+ backend: {
+ database: {
+ client: 'better-sqlite3',
+ connection: ':memory:',
+ },
+ },
+ }),
+ ).forPlugin('code-coverage');
+}
+
+const testDiscovery: jest.Mocked = {
+ getBaseUrl: jest
+ .fn()
+ .mockResolvedValue('http://localhost:7007/api/code-coverage'),
+ getExternalBaseUrl: jest.fn(),
+};
+
+const mockUrlReader = UrlReaders.default({
+ logger: getVoidLogger(),
+ config: new ConfigReader({}),
+});
+
+describe('createRouter', () => {
+ let linguistBackendApi: jest.Mocked;
+ let app: express.Express;
+
+ beforeAll(async () => {
+ const router = await createRouter({
+ linguistBackendApi,
+ config: new ConfigReader({}),
+ discovery: testDiscovery,
+ database: createDatabase(),
+ reader: mockUrlReader,
+ logger: getVoidLogger(),
+ });
+ app = express().use(router);
+ });
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('GET /health', () => {
+ it('returns ok', async () => {
+ const response = await request(app).get('/health');
+
+ expect(response.status).toEqual(200);
+ expect(response.body).toEqual({ status: 'ok' });
+ });
+ });
+});
diff --git a/plugins/linguist-backend/src/service/router.ts b/plugins/linguist-backend/src/service/router.ts
new file mode 100644
index 0000000000..091b7f408d
--- /dev/null
+++ b/plugins/linguist-backend/src/service/router.ts
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 {
+ errorHandler,
+ PluginDatabaseManager,
+ PluginEndpointDiscovery,
+ UrlReader,
+} from '@backstage/backend-common';
+import { Config } from '@backstage/config';
+import express from 'express';
+import Router from 'express-promise-router';
+import { Logger } from 'winston';
+import { LinguistBackendApi } from '../api';
+import { LinguistBackendDatabase } from '../db';
+import {
+ PluginTaskScheduler,
+ TaskScheduleDefinition,
+} from '@backstage/backend-tasks';
+
+/** @public */
+export interface RouterOptions {
+ linguistBackendApi?: LinguistBackendApi;
+ config: Config;
+ logger: Logger;
+ reader: UrlReader;
+ database: PluginDatabaseManager;
+ discovery: PluginEndpointDiscovery;
+ scheduler?: PluginTaskScheduler;
+ schedule?: TaskScheduleDefinition;
+}
+
+/** @public */
+export async function createRouter(
+ options: RouterOptions,
+): Promise {
+ const { config, logger, reader, database, discovery, scheduler, schedule } =
+ options;
+
+ const linguistBackendStore = await LinguistBackendDatabase.create(
+ await database.getClient(),
+ );
+
+ const linguistBackendApi =
+ options.linguistBackendApi ||
+ new LinguistBackendApi(
+ config,
+ logger,
+ linguistBackendStore,
+ reader,
+ discovery,
+ );
+
+ if (scheduler && schedule) {
+ logger.info(
+ `Scheduling processing of entities with: ${JSON.stringify(schedule)}`,
+ );
+ await scheduler.scheduleTask({
+ id: 'linguist_process_entities',
+ frequency: schedule.frequency,
+ timeout: schedule.timeout,
+ initialDelay: schedule.initialDelay,
+ scope: schedule.scope,
+ fn: async () => {
+ await linguistBackendApi.processEntities();
+ },
+ });
+ }
+
+ const router = Router();
+ router.use(express.json());
+
+ router.get('/health', (_, response) => {
+ response.send({ status: 'ok' });
+ });
+
+ /**
+ * /entity-languages?entity=component:default/my-component
+ */
+ router.get('/entity-languages', async (req, res) => {
+ const { entityRef: entityRef } = req.query;
+
+ if (!entityRef) {
+ throw new Error('No entityRef was provided');
+ }
+
+ const entityLanguages = await linguistBackendApi.getEntityLanguages(
+ entityRef as string,
+ );
+ res.status(200).json(entityLanguages);
+ });
+
+ router.use(errorHandler());
+ return router;
+}
diff --git a/plugins/linguist-backend/src/service/standaloneServer.ts b/plugins/linguist-backend/src/service/standaloneServer.ts
new file mode 100644
index 0000000000..5578c20e95
--- /dev/null
+++ b/plugins/linguist-backend/src/service/standaloneServer.ts
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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,
+ loadBackendConfig,
+ SingleHostDiscovery,
+ UrlReaders,
+ useHotMemoize,
+} from '@backstage/backend-common';
+import { Server } from 'http';
+import { Logger } from 'winston';
+import { createRouter } from './router';
+import knexFactory from 'knex';
+
+export interface ServerOptions {
+ port: number;
+ enableCors: boolean;
+ logger: Logger;
+}
+
+export async function startStandaloneServer(
+ options: ServerOptions,
+): Promise {
+ const logger = options.logger.child({ service: 'linguist-backend' });
+ const config = await loadBackendConfig({ logger, argv: process.argv });
+ const db = useHotMemoize(module, () => {
+ const knex = knexFactory({
+ client: 'sqlite3',
+ connection: ':memory:',
+ useNullAsDefault: true,
+ });
+
+ knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
+ resource.run('PRAGMA foreign_keys = ON', () => {});
+ });
+
+ return knex;
+ });
+
+ logger.debug('Starting application server...');
+ const router = await createRouter({
+ database: { getClient: async () => db },
+ config,
+ discovery: SingleHostDiscovery.fromConfig(config),
+ reader: UrlReaders.default({ logger, config }),
+ logger,
+ });
+
+ let service = createServiceBuilder(module)
+ .setPort(options.port)
+ .addRouter('/linguist', router);
+ if (options.enableCors) {
+ service = service.enableCors({ origin: 'http://localhost:3000' });
+ }
+
+ return await service.start().catch(err => {
+ logger.error(err);
+ process.exit(1);
+ });
+}
+
+module.hot?.accept();
diff --git a/plugins/linguist-backend/src/setupTests.ts b/plugins/linguist-backend/src/setupTests.ts
new file mode 100644
index 0000000000..813cdeaae3
--- /dev/null
+++ b/plugins/linguist-backend/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 {};
diff --git a/plugins/linguist-common/.eslintrc.js b/plugins/linguist-common/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/linguist-common/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/linguist-common/README.md b/plugins/linguist-common/README.md
new file mode 100644
index 0000000000..9408f7fcc5
--- /dev/null
+++ b/plugins/linguist-common/README.md
@@ -0,0 +1,3 @@
+# Linguist Common
+
+Common types, permissions, and constants for the Linguist plugin.
diff --git a/plugins/linguist-common/api-report.md b/plugins/linguist-common/api-report.md
new file mode 100644
index 0000000000..a9eceb3839
--- /dev/null
+++ b/plugins/linguist-common/api-report.md
@@ -0,0 +1,50 @@
+## API Report File for "@backstage/plugin-linguist-common"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { Entity } from '@backstage/catalog-model';
+
+// @public (undocumented)
+export type EntitiesOverview = {
+ entityCount: number;
+ processedCount: number;
+ pendingCount: number;
+ staleCount: number;
+ filteredEntities: Entity[];
+};
+
+// @public (undocumented)
+export type EntityResults = {
+ entityRef: string;
+ results: Languages;
+};
+
+// @public (undocumented)
+export type Language = {
+ name: string;
+ percentage: number;
+ bytes: number;
+ type: string;
+ color?: `#${string}`;
+};
+
+// @public (undocumented)
+export type Languages = {
+ languageCount: number;
+ totalBytes: number;
+ processedDate: string;
+ breakdown: Language[];
+};
+
+// @public (undocumented)
+export const LINGUIST_ANNOTATION = 'backstage.io/linguist';
+
+// @public (undocumented)
+export type ProcessedEntity = {
+ entityRef: string;
+ processed_date: Date;
+};
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/linguist-common/package.json b/plugins/linguist-common/package.json
new file mode 100644
index 0000000000..eddc114a67
--- /dev/null
+++ b/plugins/linguist-common/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@backstage/plugin-linguist-common",
+ "description": "Common functionalities for the linguist plugin",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.cjs.js",
+ "module": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "common-library"
+ },
+ "scripts": {
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/catalog-model": "workspace:^",
+ "@backstage/plugin-permission-common": "workspace:^"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/linguist-common/src/constants.ts b/plugins/linguist-common/src/constants.ts
new file mode 100644
index 0000000000..55b3d21d02
--- /dev/null
+++ b/plugins/linguist-common/src/constants.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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.
+ */
+
+/** @public */
+export const LINGUIST_ANNOTATION = 'backstage.io/linguist';
diff --git a/plugins/linguist-common/src/index.ts b/plugins/linguist-common/src/index.ts
new file mode 100644
index 0000000000..88fe08c714
--- /dev/null
+++ b/plugins/linguist-common/src/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 * from './constants';
diff --git a/plugins/linguist-common/src/setupTests.ts b/plugins/linguist-common/src/setupTests.ts
new file mode 100644
index 0000000000..8b9b6bd586
--- /dev/null
+++ b/plugins/linguist-common/src/setupTests.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 {};
diff --git a/plugins/linguist-common/src/types.ts b/plugins/linguist-common/src/types.ts
new file mode 100644
index 0000000000..01222492b6
--- /dev/null
+++ b/plugins/linguist-common/src/types.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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';
+
+/** @public */
+export type EntityResults = {
+ entityRef: string;
+ results: Languages;
+};
+
+/** @public */
+export type Languages = {
+ languageCount: number;
+ totalBytes: number;
+ processedDate: string;
+ breakdown: Language[];
+};
+
+/** @public */
+export type Language = {
+ name: string;
+ percentage: number;
+ bytes: number;
+ type: string;
+ color?: `#${string}`;
+};
+
+/** @public */
+export type ProcessedEntity = {
+ entityRef: string;
+ processed_date: Date;
+};
+
+/** @public */
+export type EntitiesOverview = {
+ entityCount: number;
+ processedCount: number;
+ pendingCount: number;
+ staleCount: number;
+ filteredEntities: Entity[];
+};
diff --git a/plugins/linguist/.eslintrc.js b/plugins/linguist/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/linguist/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/linguist/README.md b/plugins/linguist/README.md
new file mode 100644
index 0000000000..d5ca2199dd
--- /dev/null
+++ b/plugins/linguist/README.md
@@ -0,0 +1,88 @@
+# Linguist
+
+Welcome to the Linguist plugin!
+
+## Features
+
+The Linguist plugin consists of a card that will give you the breakdown of the languages used by the configured Entity in the Catalog.
+
+When there is no Linguist data yet the card will be empty:
+
+
+
+Clicking on the refresh icon will generate the language breakdown:
+
+
+
+Here's an example where the permissions are hiding the refresh button:
+
+
+
+## Setup
+
+The following sections will help you get the Linguist plugin setup and running.
+
+### Backend
+
+You need to setup the [Linguist backend plugin](../linguist-backend/README.md) before you move forward with any of the following steps if you haven't already.
+
+### Entity Annotation
+
+To be able to use the Linguist plugin you need to add the following annotation to any entities you want to use it with:
+
+```yaml
+backstage.io/linguist: https://url.to/your/srouce/code
+```
+
+Linguist uses the `UrlReader` to pull down the files before it scans them to determine the languages, this means you can pull files from all the supported providers - GitHub, GitLab, Bitbucket, Azure, Google GCS, AWS S3, etc.
+
+Here's what that will look like in action using the Backstage repo as an example:
+
+```yaml
+# Example catalog-info.yaml entity definition file
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ # ...
+ annotations:
+ backstage.io/linguist: https://github.com/backstage/backstage
+spec:
+ type: service
+ # ...
+```
+
+### Frontend
+
+To setup the Linguist Card frontend you'll need to do the following steps:
+
+1. First we need to add the `@backstage/plugin-linguist` package to your frontend app:
+
+ ```sh
+ # From your Backstage root directory
+ yarn add --cwd packages/app @backstage/plugin-linguist
+ ```
+
+2. Second we need to add the `EntityLinguistCard` extension to the entity page in your app:
+
+ ```tsx
+ // In packages/app/src/components/catalog/EntityPage.tsx
+ import { isLinguistAvailable, EntityLinguistCard } from '@backstage/plugin-linguist';
+
+ // For example in the Overview section
+ const overviewContent = (
+
+ // ...
+
+
+
+
+
+
+
+ // ...
+
+ ```
+
+**Notes:**
+
+- The `if` prop is optional on the `EntitySwitch.Case`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation
diff --git a/plugins/linguist/api-report.md b/plugins/linguist/api-report.md
new file mode 100644
index 0000000000..469b3b93c0
--- /dev/null
+++ b/plugins/linguist/api-report.md
@@ -0,0 +1,21 @@
+## API Report File for "@backstage/plugin-linguist"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+///
+
+import { BackstagePlugin } from '@backstage/core-plugin-api';
+import { Entity } from '@backstage/catalog-model';
+
+// @public (undocumented)
+export const EntityLinguistCard: () => JSX.Element;
+
+// @public (undocumented)
+export const isLinguistAvailable: (entity: Entity) => boolean;
+
+// @public (undocumented)
+export const linguistPlugin: BackstagePlugin<{}, {}, {}>;
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/linguist/dev/index.tsx b/plugins/linguist/dev/index.tsx
new file mode 100644
index 0000000000..bb6fb64b52
--- /dev/null
+++ b/plugins/linguist/dev/index.tsx
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 { createDevApp } from '@backstage/dev-utils';
+import { linguistPlugin, EntityLinguistCard } from '../src/plugin';
+
+createDevApp()
+ .registerPlugin(linguistPlugin)
+ .addPage({
+ element: ,
+ title: 'Root Page',
+ path: '/linguist',
+ })
+ .render();
diff --git a/plugins/linguist/docs/linguist-no-data.png b/plugins/linguist/docs/linguist-no-data.png
new file mode 100644
index 0000000000..208b8df5d5
Binary files /dev/null and b/plugins/linguist/docs/linguist-no-data.png differ
diff --git a/plugins/linguist/docs/linguist-no-refresh.png b/plugins/linguist/docs/linguist-no-refresh.png
new file mode 100644
index 0000000000..a3b4e4fde7
Binary files /dev/null and b/plugins/linguist/docs/linguist-no-refresh.png differ
diff --git a/plugins/linguist/docs/linguist-with-data.png b/plugins/linguist/docs/linguist-with-data.png
new file mode 100644
index 0000000000..e673f994fa
Binary files /dev/null and b/plugins/linguist/docs/linguist-with-data.png differ
diff --git a/plugins/linguist/package.json b/plugins/linguist/package.json
new file mode 100644
index 0000000000..18a9ca0ac5
--- /dev/null
+++ b/plugins/linguist/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "@backstage/plugin-linguist",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "frontend-plugin"
+ },
+ "homepage": "https://backstage.io",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "plugins/linguist"
+ },
+ "scripts": {
+ "start": "backstage-cli package start",
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/catalog-model": "workspace:^",
+ "@backstage/core-components": "workspace:^",
+ "@backstage/core-plugin-api": "workspace:^",
+ "@backstage/errors": "workspace:^",
+ "@backstage/plugin-catalog-react": "workspace:^",
+ "@backstage/plugin-linguist-common": "workspace:^",
+ "@backstage/theme": "workspace:^",
+ "@material-ui/core": "^4.9.13",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "^4.0.0-alpha.57",
+ "luxon": "^2.0.2",
+ "react-use": "^17.2.4",
+ "slugify": "^1.6.4"
+ },
+ "peerDependencies": {
+ "react": "^16.13.1 || ^17.0.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^",
+ "@backstage/core-app-api": "workspace:^",
+ "@backstage/dev-utils": "workspace:^",
+ "@backstage/test-utils": "workspace:^",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^12.1.3",
+ "@testing-library/user-event": "^14.0.0",
+ "@types/node": "*",
+ "cross-fetch": "^3.1.5",
+ "msw": "^0.49.0"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/linguist/src/api/LinguistApi.ts b/plugins/linguist/src/api/LinguistApi.ts
new file mode 100644
index 0000000000..6593453f31
--- /dev/null
+++ b/plugins/linguist/src/api/LinguistApi.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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-plugin-api';
+import { Languages } from '@backstage/plugin-linguist-common';
+
+export const linguistApiRef = createApiRef({
+ id: 'plugin.linguist.service',
+});
+
+export interface LinguistApi {
+ getLanguages(entityRef: string): Promise;
+}
diff --git a/plugins/linguist/src/api/LinguistClient.ts b/plugins/linguist/src/api/LinguistClient.ts
new file mode 100644
index 0000000000..3e327ff21e
--- /dev/null
+++ b/plugins/linguist/src/api/LinguistClient.ts
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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, IdentityApi } from '@backstage/core-plugin-api';
+import { ResponseError } from '@backstage/errors';
+import { Languages } from '@backstage/plugin-linguist-common';
+import { LinguistApi } from './LinguistApi';
+
+export class LinguistClient implements LinguistApi {
+ private readonly discoveryApi: DiscoveryApi;
+ private readonly identityApi: IdentityApi;
+
+ public constructor(options: {
+ discoveryApi: DiscoveryApi;
+ identityApi: IdentityApi;
+ }) {
+ this.discoveryApi = options.discoveryApi;
+ this.identityApi = options.identityApi;
+ }
+
+ public async getLanguages(entityRef: string): Promise {
+ const queryString = new URLSearchParams();
+ queryString.append('entityRef', entityRef);
+
+ const urlSegment = `entity-languages?${queryString}`;
+
+ const items = await this.get(urlSegment);
+ return items;
+ }
+
+ private async get(path: string): Promise {
+ const baseUrl = `${await this.discoveryApi.getBaseUrl('linguist')}/`;
+ const url = new URL(path, baseUrl);
+
+ const { token } = await this.identityApi.getCredentials();
+ const response = await fetch(url.toString(), {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+
+ if (!response.ok) {
+ throw await ResponseError.fromResponse(response);
+ }
+
+ return response.json() as Promise;
+ }
+}
diff --git a/plugins/linguist/src/api/index.ts b/plugins/linguist/src/api/index.ts
new file mode 100644
index 0000000000..ca60b60d53
--- /dev/null
+++ b/plugins/linguist/src/api/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 './LinguistApi';
+export * from './LinguistClient';
diff --git a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx
new file mode 100644
index 0000000000..6014487d30
--- /dev/null
+++ b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 {
+ Box,
+ Chip,
+ Tooltip,
+ Typography,
+ makeStyles,
+ Grid,
+ useTheme,
+} from '@material-ui/core';
+import { InfoCard, Progress } from '@backstage/core-components';
+import Alert from '@material-ui/lab/Alert';
+import { DateTime } from 'luxon';
+import React from 'react';
+import slugify from 'slugify';
+import { useEntity } from '@backstage/plugin-catalog-react';
+import { useLanguages } from '../../hooks';
+
+const useStyles = makeStyles(theme => ({
+ infoCard: {
+ marginBottom: theme.spacing(3),
+ },
+ barContainer: {
+ height: theme.spacing(2),
+ marginBottom: theme.spacing(3),
+ borderRadius: '4px',
+ backgroundColor: 'transparent',
+ overflow: 'hidden',
+ },
+ bar: {
+ height: '100%',
+ position: 'relative',
+ },
+ languageDot: {
+ width: '10px',
+ height: '10px',
+ borderRadius: '50%',
+ marginRight: theme.spacing(1),
+ display: 'inline-block',
+ },
+ label: {
+ color: 'inherit',
+ },
+}));
+
+export const LinguistCard = () => {
+ const classes = useStyles();
+ const theme = useTheme();
+ const { entity } = useEntity();
+ const { items, loading, error } = useLanguages(entity);
+ let barWidth = 0;
+
+ if (loading) {
+ return ;
+ } else if (error) {
+ return {error.message};
+ }
+
+ if (items && items.languageCount === 0 && items.totalBytes === 0) {
+ return (
+
+
+
+
+ There is currently no language data for this entity.
+
+
+
+
+ );
+ }
+
+ const breakdown = items?.breakdown.sort((a, b) =>
+ a.percentage < b.percentage ? 1 : -1,
+ );
+ const processedDate = items?.processedDate;
+
+ return breakdown && processedDate ? (
+
+
+ {breakdown.map((language, index: number) => {
+ barWidth = barWidth + language.percentage;
+ return (
+
+
+
+ );
+ })}
+
+
+
+ {breakdown.map(languages => (
+
+
+ {languages.name} - {languages.percentage}%
+
+ }
+ variant="outlined"
+ key={slugify(languages.name, { lower: true })}
+ />
+ ))}
+
+
+
+ ) : (
+ <>>
+ );
+};
diff --git a/plugins/linguist/src/components/LinguistCard/index.ts b/plugins/linguist/src/components/LinguistCard/index.ts
new file mode 100644
index 0000000000..aefae143fd
--- /dev/null
+++ b/plugins/linguist/src/components/LinguistCard/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 { LinguistCard } from './LinguistCard';
diff --git a/plugins/linguist/src/hooks/index.ts b/plugins/linguist/src/hooks/index.ts
new file mode 100644
index 0000000000..6f8913cf35
--- /dev/null
+++ b/plugins/linguist/src/hooks/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 './useLanguages';
diff --git a/plugins/linguist/src/hooks/useLanguages.ts b/plugins/linguist/src/hooks/useLanguages.ts
new file mode 100644
index 0000000000..3e99243e94
--- /dev/null
+++ b/plugins/linguist/src/hooks/useLanguages.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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, stringifyEntityRef } from '@backstage/catalog-model';
+import { linguistApiRef } from '../api';
+import { useApi } from '@backstage/core-plugin-api';
+import useAsync from 'react-use/lib/useAsync';
+
+import { Languages } from '@backstage/plugin-linguist-common';
+
+export function useLanguages(entity: Entity): {
+ items?: Languages;
+ loading: boolean;
+ error?: Error;
+} {
+ const entityRef = stringifyEntityRef(entity);
+
+ const api = useApi(linguistApiRef);
+ const { value, loading, error } = useAsync(() => {
+ return api.getLanguages(entityRef);
+ }, [api, entityRef]);
+
+ return {
+ items: value,
+ loading,
+ error,
+ };
+}
diff --git a/plugins/linguist/src/index.ts b/plugins/linguist/src/index.ts
new file mode 100644
index 0000000000..ce9cd98cbd
--- /dev/null
+++ b/plugins/linguist/src/index.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 {
+ linguistPlugin,
+ isLinguistAvailable,
+ EntityLinguistCard,
+} from './plugin';
diff --git a/plugins/linguist/src/plugin.test.ts b/plugins/linguist/src/plugin.test.ts
new file mode 100644
index 0000000000..d2a1016a22
--- /dev/null
+++ b/plugins/linguist/src/plugin.test.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 { linguistPlugin } from './plugin';
+
+describe('linguist', () => {
+ it('should export plugin', () => {
+ expect(linguistPlugin).toBeDefined();
+ });
+});
diff --git a/plugins/linguist/src/plugin.ts b/plugins/linguist/src/plugin.ts
new file mode 100644
index 0000000000..917a13ca9a
--- /dev/null
+++ b/plugins/linguist/src/plugin.ts
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 {
+ createApiFactory,
+ createComponentExtension,
+ createPlugin,
+ discoveryApiRef,
+ identityApiRef,
+} from '@backstage/core-plugin-api';
+import { linguistApiRef, LinguistClient } from './api';
+import { LINGUIST_ANNOTATION } from '@backstage/plugin-linguist-common';
+import { Entity } from '@backstage/catalog-model';
+
+/** @public */
+export const isLinguistAvailable = (entity: Entity) =>
+ Boolean(entity.metadata.annotations?.[LINGUIST_ANNOTATION]);
+
+/** @public */
+export const linguistPlugin = createPlugin({
+ id: 'linguist',
+ apis: [
+ createApiFactory({
+ api: linguistApiRef,
+ deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
+ factory: ({ discoveryApi, identityApi }) =>
+ new LinguistClient({ discoveryApi, identityApi }),
+ }),
+ ],
+});
+
+/** @public */
+export const EntityLinguistCard = linguistPlugin.provide(
+ createComponentExtension({
+ name: 'EntityLinguistCard',
+ component: {
+ lazy: () => import('./components/LinguistCard').then(m => m.LinguistCard),
+ },
+ }),
+);
diff --git a/plugins/linguist/src/setupTests.ts b/plugins/linguist/src/setupTests.ts
new file mode 100644
index 0000000000..2b5b615b19
--- /dev/null
+++ b/plugins/linguist/src/setupTests.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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';
+import 'cross-fetch/polyfill';
diff --git a/yarn.lock b/yarn.lock
index 345c28a8b5..9697e098ce 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6895,6 +6895,78 @@ __metadata:
languageName: unknown
linkType: soft
+"@backstage/plugin-linguist-backend@workspace:^, @backstage/plugin-linguist-backend@workspace:plugins/linguist-backend":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-linguist-backend@workspace:plugins/linguist-backend"
+ dependencies:
+ "@backstage/backend-common": "workspace:^"
+ "@backstage/backend-tasks": "workspace:^"
+ "@backstage/catalog-client": "workspace:^"
+ "@backstage/catalog-model": "workspace:^"
+ "@backstage/cli": "workspace:^"
+ "@backstage/config": "workspace:^"
+ "@backstage/errors": "workspace:^"
+ "@backstage/plugin-auth-node": "workspace:^"
+ "@backstage/plugin-linguist-common": "workspace:^"
+ "@types/express": "*"
+ "@types/supertest": ^2.0.8
+ express: ^4.18.1
+ express-promise-router: ^4.1.0
+ fs-extra: ^10.0.0
+ knex: ^2.0.0
+ linguist-js: ^2.5.3
+ luxon: ^2.0.2
+ msw: ^0.49.0
+ node-fetch: ^2.6.7
+ supertest: ^6.2.4
+ uuid: ^8.3.2
+ winston: ^3.2.1
+ yn: ^4.0.0
+ languageName: unknown
+ linkType: soft
+
+"@backstage/plugin-linguist-common@workspace:^, @backstage/plugin-linguist-common@workspace:plugins/linguist-common":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-linguist-common@workspace:plugins/linguist-common"
+ dependencies:
+ "@backstage/catalog-model": "workspace:^"
+ "@backstage/cli": "workspace:^"
+ "@backstage/plugin-permission-common": "workspace:^"
+ languageName: unknown
+ linkType: soft
+
+"@backstage/plugin-linguist@workspace:^, @backstage/plugin-linguist@workspace:plugins/linguist":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-linguist@workspace:plugins/linguist"
+ dependencies:
+ "@backstage/catalog-model": "workspace:^"
+ "@backstage/cli": "workspace:^"
+ "@backstage/core-app-api": "workspace:^"
+ "@backstage/core-components": "workspace:^"
+ "@backstage/core-plugin-api": "workspace:^"
+ "@backstage/dev-utils": "workspace:^"
+ "@backstage/errors": "workspace:^"
+ "@backstage/plugin-catalog-react": "workspace:^"
+ "@backstage/plugin-linguist-common": "workspace:^"
+ "@backstage/test-utils": "workspace:^"
+ "@backstage/theme": "workspace:^"
+ "@material-ui/core": ^4.9.13
+ "@material-ui/icons": ^4.9.1
+ "@material-ui/lab": ^4.0.0-alpha.57
+ "@testing-library/jest-dom": ^5.10.1
+ "@testing-library/react": ^12.1.3
+ "@testing-library/user-event": ^14.0.0
+ "@types/node": "*"
+ cross-fetch: ^3.1.5
+ luxon: ^2.0.2
+ msw: ^0.49.0
+ react-use: ^17.2.4
+ slugify: ^1.6.4
+ peerDependencies:
+ react: ^16.13.1 || ^17.0.0
+ languageName: unknown
+ linkType: soft
+
"@backstage/plugin-microsoft-calendar@workspace:^, @backstage/plugin-microsoft-calendar@workspace:plugins/microsoft-calendar":
version: 0.0.0-use.local
resolution: "@backstage/plugin-microsoft-calendar@workspace:plugins/microsoft-calendar"
@@ -11244,7 +11316,7 @@ __metadata:
languageName: node
linkType: hard
-"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4":
+"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4, @material-ui/core@npm:^4.9.13":
version: 4.12.4
resolution: "@material-ui/core@npm:4.12.4"
dependencies:
@@ -17580,10 +17652,10 @@ __metadata:
languageName: node
linkType: hard
-"binary-extensions@npm:^2.0.0":
- version: 2.0.0
- resolution: "binary-extensions@npm:2.0.0"
- checksum: 554f65d3378cf71c3185c17dec3ca58334b8ff6ae242db3107284765ce33b2af19efd20c11faec41907a40534929e34b3a98e7d391c61e4211b45732dccb1115
+"binary-extensions@npm:^2.0.0, binary-extensions@npm:^2.2.0":
+ version: 2.2.0
+ resolution: "binary-extensions@npm:2.2.0"
+ checksum: ccd267956c58d2315f5d3ea6757cf09863c5fc703e50fbeb13a7dc849b812ef76e3cf9ca8f35a0c48498776a7478d7b4a0418e1e2b8cb9cb9731f2922aaad7f8
languageName: node
linkType: hard
@@ -18943,7 +19015,7 @@ __metadata:
languageName: node
linkType: hard
-"commander@npm:*, commander@npm:^9.1.0, commander@npm:^9.4.1":
+"commander@npm:*, commander@npm:^9.1.0, commander@npm:^9.4.1, commander@npm:^9.5.0":
version: 9.5.0
resolution: "commander@npm:9.5.0"
checksum: c7a3e27aa59e913b54a1bafd366b88650bc41d6651f0cbe258d4ff09d43d6a7394232a4dadd0bf518b3e696fdf595db1028a0d82c785b88bd61f8a440cecfade
@@ -22227,6 +22299,8 @@ __metadata:
"@backstage/plugin-kafka": "workspace:^"
"@backstage/plugin-kubernetes": "workspace:^"
"@backstage/plugin-lighthouse": "workspace:^"
+ "@backstage/plugin-linguist": "workspace:^"
+ "@backstage/plugin-linguist-common": "workspace:^"
"@backstage/plugin-microsoft-calendar": "workspace:^"
"@backstage/plugin-newrelic": "workspace:^"
"@backstage/plugin-newrelic-dashboard": "workspace:^"
@@ -22326,6 +22400,7 @@ __metadata:
"@backstage/plugin-kafka-backend": "workspace:^"
"@backstage/plugin-kubernetes-backend": "workspace:^"
"@backstage/plugin-lighthouse-backend": "workspace:^"
+ "@backstage/plugin-linguist-backend": "workspace:^"
"@backstage/plugin-permission-backend": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
@@ -24806,10 +24881,10 @@ __metadata:
languageName: node
linkType: hard
-"ignore@npm:^5.1.4, ignore@npm:^5.2.0":
- version: 5.2.0
- resolution: "ignore@npm:5.2.0"
- checksum: 6b1f926792d614f64c6c83da3a1f9c83f6196c2839aa41e1e32dd7b8d174cef2e329d75caabb62cb61ce9dc432f75e67d07d122a037312db7caa73166a1bdb77
+"ignore@npm:^5.1.4, ignore@npm:^5.2.0, ignore@npm:^5.2.4":
+ version: 5.2.4
+ resolution: "ignore@npm:5.2.4"
+ checksum: 3d4c309c6006e2621659311783eaea7ebcd41fe4ca1d78c91c473157ad6666a57a2df790fe0d07a12300d9aac2888204d7be8d59f9aaf665b1c7fcdb432517ef
languageName: node
linkType: hard
@@ -25826,7 +25901,7 @@ __metadata:
languageName: node
linkType: hard
-"isbinaryfile@npm:^4.0.10, isbinaryfile@npm:^4.0.8":
+"isbinaryfile@npm:^4.0.10, isbinaryfile@npm:^4.0.8, isbinaryfile@npm:^4.0.8 <5":
version: 4.0.10
resolution: "isbinaryfile@npm:4.0.10"
checksum: a6b28db7e23ac7a77d3707567cac81356ea18bd602a4f21f424f862a31d0e7ab4f250759c98a559ece35ffe4d99f0d339f1ab884ffa9795172f632ab8f88e686
@@ -27699,6 +27774,25 @@ __metadata:
languageName: node
linkType: hard
+"linguist-js@npm:^2.5.3":
+ version: 2.5.4
+ resolution: "linguist-js@npm:2.5.4"
+ dependencies:
+ binary-extensions: ^2.2.0
+ commander: ^9.5.0
+ common-path-prefix: ^3.0.0
+ cross-fetch: ^3.1.5
+ ignore: ^5.2.4
+ isbinaryfile: ^4.0.8 <5
+ js-yaml: ^4.1.0
+ node-cache: ^5.1.2
+ bin:
+ linguist: bin/index.js
+ linguist-js: bin/index.js
+ checksum: 92ebfc45f0f17a056153068f7e939a3e6755307d3696673597e824011d83b698063241aa7d082a7f9059416efce85a49056f0a31b3158c25f79e273e2f51e309
+ languageName: node
+ linkType: hard
+
"linkify-it@npm:^3.0.1":
version: 3.0.2
resolution: "linkify-it@npm:3.0.2"
@@ -28244,6 +28338,13 @@ __metadata:
languageName: node
linkType: hard
+"luxon@npm:^2.0.2":
+ version: 2.5.2
+ resolution: "luxon@npm:2.5.2"
+ checksum: d8b671ffd2ff0b438af862ac11082a81b3aedd9f8b6a4ca636f944224f8dd381125cd4d274ca0a8aedcaf2cfca0fc2ca96fe4cc1b16a28b7af701afb95c0bff8
+ languageName: node
+ linkType: hard
+
"luxon@npm:^3.0.0, luxon@npm:^3.2.1":
version: 3.2.1
resolution: "luxon@npm:3.2.1"
@@ -35075,6 +35176,13 @@ __metadata:
languageName: node
linkType: hard
+"slugify@npm:^1.6.4":
+ version: 1.6.5
+ resolution: "slugify@npm:1.6.5"
+ checksum: a955a1b600201030f4c1daa9bb74a17d4402a0693fc40978bbd17e44e64fd72dad3bac4037422aa8aed55b5170edd57f3f4cd8f59ba331f5cf0f10f1a7795609
+ languageName: node
+ linkType: hard
+
"smart-buffer@npm:^4.2.0":
version: 4.2.0
resolution: "smart-buffer@npm:4.2.0"