Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ npm run build && npm run build:docs
* [`rdme changelog`](documentation/commands/changelog.md) - Upload Markdown files to the Changelog section of your ReadMe project.
* [`rdme custompages`](documentation/commands/custompages.md) - Upload Markdown or HTML files to the Custom Pages section of your ReadMe project.
* [`rdme docs`](documentation/commands/docs.md) - Upload or export Guides in your ReadMe project.
* [`rdme glossary`](documentation/commands/glossary.md) - Upload or export glossary terms in your ReadMe project.
* [`rdme help`](documentation/commands/help.md) - Display help for rdme.
* [`rdme login`](documentation/commands/login.md) - Login to a ReadMe project.
* [`rdme logout`](documentation/commands/logout.md) - Logs the currently authenticated user out of ReadMe.
Expand Down
103 changes: 103 additions & 0 deletions documentation/commands/glossary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
`rdme glossary`
===============

Upload or export glossary terms in your ReadMe project.

* [`rdme glossary export FILE`](#rdme-glossary-export-file)
* [`rdme glossary upload FILE`](#rdme-glossary-upload-file)

## `rdme glossary export FILE`

Export glossary terms from your ReadMe project to a JSON file.

```
USAGE
$ rdme glossary export FILE --key <value> [--include-group]

ARGUMENTS
FILE JSON file to write the exported glossary to.

FLAGS
--key=<value> (required) ReadMe project API key
--include-group Include terms inherited from the project’s Enterprise group in a separate `group_terms` list. Only
applicable to projects within an Enterprise group.

DESCRIPTION
Export glossary terms from your ReadMe project to a JSON file.

Exports project terms by default and writes them to a `terms` list, ready for `rdme glossary upload`. The destination
file is overwritten if it already exists.

Use `--include-group` to also export terms inherited from an Enterprise group in a separate `group_terms` list. Only
applicable to projects within an Enterprise group.

`group_terms` is omitted when group terms are not requested or the project has no Enterprise group.

Within each list, the first occurrence of a name is retained, ignoring case and surrounding whitespace. Matching names
in the two lists are preserved.

EXAMPLES
Export project terms to a JSON file:

$ rdme glossary export glossary.json

Also export terms inherited from your Enterprise group:

$ rdme glossary export glossary.json --include-group

FLAG DESCRIPTIONS
--key=<value> ReadMe project API key

An API key for your ReadMe project. Note that API authentication is required despite being omitted from the example
usage. See our docs for more information: https://github.com/readmeio/rdme/tree/v10#authentication
```

## `rdme glossary upload FILE`

Upload glossary terms to your ReadMe project from a JSON file.

```
USAGE
$ rdme glossary upload FILE --key <value> [--dry-run] [--replace]

ARGUMENTS
FILE JSON file containing glossary terms to upload.

FLAGS
--key=<value> (required) ReadMe project API key
--dry-run Preview the resulting project terms without saving changes.
--replace Replace all project terms instead of merging.

DESCRIPTION
Upload glossary terms to your ReadMe project from a JSON file.

The JSON file must contain a `terms` array of objects with non-empty `term` and `definition` strings. For example: `{
"terms": [{ "term": "API", "definition": "Application programming interface" }] }`.

By default, matching project terms are updated in place, omitted terms are retained, and new terms are added at the
top in file order. Names are matched ignoring case and surrounding whitespace; the first occurrence in the file wins.

Use `--replace` to replace all project terms, or `{ "terms": [] }` with `--replace` to clear them. Use `--dry-run` to
preview the resulting project terms without saving changes.

`group_terms` from an export is ignored; only project terms are uploaded.

EXAMPLES
Merge terms from a JSON file into your project glossary:

$ rdme glossary upload glossary.json

Preview the resulting project terms without saving changes:

$ rdme glossary upload glossary.json --dry-run

Replace all project terms with the terms in the file:

$ rdme glossary upload glossary.json --replace

FLAG DESCRIPTIONS
--key=<value> ReadMe project API key

An API key for your ReadMe project. Note that API authentication is required despite being omitted from the example
usage. See our docs for more information: https://github.com/readmeio/rdme/tree/v10#authentication
```
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@
"docs": {
"description": "Upload or export Guides in your ReadMe project."
},
"glossary": {
"description": "Upload or export glossary terms in your ReadMe project."
},
"openapi": {
"description": "Manage your API definition (e.g., syncing, validation, analysis, conversion, etc.). Supports OpenAPI, Swagger, and Postman collections, in either JSON or YAML formats."
},
Expand Down
61 changes: 61 additions & 0 deletions src/commands/glossary/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Glossary } from '../../lib/glossary.js';

import fs from 'node:fs/promises';
import path from 'node:path';

import { Args, Flags } from '@oclif/core';

import BaseCommand from '../../lib/baseCommand.js';
import { keyFlag } from '../../lib/flags.js';

export default class GlossaryExportCommand extends BaseCommand<typeof GlossaryExportCommand> {
id = 'glossary export' as const;

static summary = 'Export glossary terms from your ReadMe project to a JSON file.';

static description = [
'Exports project terms by default and writes them to a `terms` list, ready for `<%= config.bin %> glossary upload`. The destination file is overwritten if it already exists.',
'Use `--include-group` to also export terms inherited from an Enterprise group in a separate `group_terms` list. Only applicable to projects within an Enterprise group.',
'`group_terms` is omitted when group terms are not requested or the project has no Enterprise group.',
'Within each list, the first occurrence of a name is retained, ignoring case and surrounding whitespace. Matching names in the two lists are preserved.',
].join('\n\n');

static args = {
file: Args.string({ description: 'JSON file to write the exported glossary to.', required: true }),
};

static flags = {
key: keyFlag,
'include-group': Flags.boolean({
description:
'Include terms inherited from the project’s Enterprise group in a separate `group_terms` list. Only applicable to projects within an Enterprise group.',
}),
};

static examples = [
{
description: 'Export project terms to a JSON file:',
command: '<%= config.bin %> <%= command.id %> glossary.json',
},
{
description: 'Also export terms inherited from your Enterprise group:',
command: '<%= config.bin %> <%= command.id %> glossary.json --include-group',
},
];

async run() {
const query = this.flags['include-group'] ? '?include_group=true' : '';
const response = await this.readmeAPIFetch(`/projects/me/glossary${query}`, {
headers: { authorization: `Bearer ${this.flags.key}` },
});
const { data } = await this.handleAPIRes<{ data: Glossary }>(response);
const glossary = { terms: data.terms, ...(data.group_terms !== null ? { group_terms: data.group_terms } : {}) };

await fs.mkdir(path.dirname(this.args.file), { recursive: true });
await fs.writeFile(this.args.file, `${JSON.stringify(glossary, null, 2)}\n`, 'utf8');
this.info(
`Exported ${data.terms.length} project terms${data.group_terms ? ` and ${data.group_terms.length} group terms` : ''} to ${this.args.file}.`,
);
return glossary;
}
}
90 changes: 90 additions & 0 deletions src/commands/glossary/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Glossary, GlossaryUploadResponse } from '../../lib/glossary.js';

import fs from 'node:fs/promises';

import { Args, Flags } from '@oclif/core';

import BaseCommand from '../../lib/baseCommand.js';
import { keyFlag } from '../../lib/flags.js';
import { parseGlossary, previewGlossary } from '../../lib/glossary.js';
import { isRecord } from '../../utils.js';

export default class GlossaryUploadCommand extends BaseCommand<typeof GlossaryUploadCommand> {
id = 'glossary upload' as const;

static summary = 'Upload glossary terms to your ReadMe project from a JSON file.';

static description = [
'The JSON file must contain a `terms` array of objects with non-empty `term` and `definition` strings. For example: `{ "terms": [{ "term": "API", "definition": "Application programming interface" }] }`.',
'By default, matching project terms are updated in place, omitted terms are retained, and new terms are added at the top in file order. Names are matched ignoring case and surrounding whitespace; the first occurrence in the file wins.',
'Use `--replace` to replace all project terms, or `{ "terms": [] }` with `--replace` to clear them. Use `--dry-run` to preview the resulting project terms without saving changes.',
'`group_terms` from an export is ignored; only project terms are uploaded.',
].join('\n\n');

static args = {
file: Args.string({ description: 'JSON file containing glossary terms to upload.', required: true }),
};

static flags = {
key: keyFlag,
'dry-run': Flags.boolean({ description: 'Preview the resulting project terms without saving changes.' }),
replace: Flags.boolean({ description: 'Replace all project terms instead of merging.' }),
};

static examples = [
{
description: 'Merge terms from a JSON file into your project glossary:',
command: '<%= config.bin %> <%= command.id %> glossary.json',
},
{
description: 'Preview the resulting project terms without saving changes:',
command: '<%= config.bin %> <%= command.id %> glossary.json --dry-run',
},
{
description: 'Replace all project terms with the terms in the file:',
command: '<%= config.bin %> <%= command.id %> glossary.json --replace',
},
];

async run() {
const source = await fs.readFile(this.args.file, 'utf8');
let input: unknown;
try {
input = JSON.parse(source);
} catch {
throw new Error(`Unable to parse ${this.args.file}: expected a valid JSON glossary file.`);
}

const glossary = parseGlossary(input);
if (isRecord(input) && input.group_terms != null) {
this.warn('Ignoring group_terms. Only project terms are uploaded.');
}
const headers = { authorization: `Bearer ${this.flags.key}`, 'Content-Type': 'application/json' };
const replace = this.flags.replace ?? false;

if (this.flags['dry-run']) {
const response = await this.readmeAPIFetch('/projects/me/glossary', { headers });
const { data } = await this.handleAPIRes<{ data: Glossary }>(response);
const terms = previewGlossary(data.terms, glossary.terms, replace);
this.info(`Dry run: would ${replace ? 'replace' : 'merge'} project glossary terms. No changes saved.`);
this.log(JSON.stringify({ terms }, null, 2));
return { data: { terms }, dry_run: true };
}

const response = await this.readmeAPIFetch(
'/projects/me/glossary',
{
method: replace ? 'PUT' : 'PATCH',
headers,
body: JSON.stringify({ terms: glossary.terms }),
},
{ file: { path: this.args.file, type: 'path' } },
);
const result = await this.handleAPIRes<GlossaryUploadResponse>(response);
const { added, updated, removed, duplicates_ignored: duplicates } = result.data.changes;
this.info(
`Glossary uploaded: ${added} added, ${updated} updated, ${removed} removed, ${duplicates} duplicate entries ignored.`,
);
return result;
}
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import CustomPagesUploadCommand from './commands/custompages/upload.js';
import DocsExportCommand from './commands/docs/export.js';
import DocsMigrateCommand from './commands/docs/migrate.js';
import DocsUploadCommand from './commands/docs/upload.js';
import GlossaryExportCommand from './commands/glossary/export.js';
import GlossaryUploadCommand from './commands/glossary/upload.js';
import LoginCommand from './commands/login.js';
import LogoutCommand from './commands/logout.js';
import OpenAPIConvertCommand from './commands/openapi/convert.js';
Expand Down Expand Up @@ -44,6 +46,9 @@ export const COMMANDS = {
'docs:migrate': DocsMigrateCommand,
'docs:upload': DocsUploadCommand,

'glossary:export': GlossaryExportCommand,
'glossary:upload': GlossaryUploadCommand,

login: LoginCommand,
logout: LogoutCommand,

Expand Down
69 changes: 69 additions & 0 deletions src/lib/glossary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { isRecord } from '../utils.js';

export interface GlossaryTerm {
definition: string;
term: string;
}

export interface Glossary {
group_terms: GlossaryTerm[] | null;
terms: GlossaryTerm[];
}

export interface GlossaryUploadResponse {
data: {
changes: { added: number; updated: number; removed: number; duplicates_ignored: number };
terms: GlossaryTerm[];
};
}

const parseTerms = (terms: unknown): GlossaryTerm[] => {
if (!Array.isArray(terms)) throw new Error('Glossary terms must be an array.');
return terms.map((entry: unknown, index) => {
if (
!isRecord(entry) ||
typeof entry.term !== 'string' ||
!entry.term.trim() ||
typeof entry.definition !== 'string' ||
!entry.definition.trim() ||
Object.keys(entry).some(key => !['term', 'definition'].includes(key))
) {
throw new Error(`Glossary entry ${index + 1} must contain only a non-empty "term" and "definition".`);
}
return { term: entry.term.trim(), definition: entry.definition.trim() };
});
};

/** Validate the upload before making requests, including during a dry run. */
export function parseGlossary(input: unknown): Pick<Glossary, 'terms'> {
if (!isRecord(input) || !Array.isArray(input.terms)) {
throw new Error(
'Glossary files must contain a "terms" array. Use { "terms": [] } to clear project terms with --replace.',
);
}
if (Object.keys(input).some(key => !['terms', 'group_terms'].includes(key))) {
throw new Error('Glossary files only support "terms" and optional "group_terms" lists.');
}

return {
terms: parseTerms(input.terms),
};
}

const normalize = (term: string) => term.trim().toLowerCase();

/** Preview the exported result; hidden stored duplicates are managed by the API. */
export function previewGlossary(existing: GlossaryTerm[], uploaded: GlossaryTerm[], replace: boolean): GlossaryTerm[] {
const incoming = new Map<string, GlossaryTerm>();
uploaded.forEach(term => {
const key = normalize(term.term);
if (!incoming.has(key)) incoming.set(key, term);
});
if (replace) return [...incoming.values()];

const names = new Set(existing.map(({ term }) => normalize(term)));
return [
...[...incoming].filter(([key]) => !names.has(key)).map(([, term]) => term),
...existing.map(term => incoming.get(normalize(term.term)) ?? term),
];
}
Loading
Loading