@tryghost/algolia 0.2.11 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2013-2023 Ghost Foundation
3
+ Copyright (c) 2013-2025 Ghost Foundation
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,70 +1,73 @@
1
1
  # Algolia Ghost CLI
2
2
 
3
- CLI tool to initially index the full Ghost post content into an Algolia index.
3
+ `@tryghost/algolia` is a CLI for initially indexing the full published content of a Ghost site in Algolia.
4
+
5
+ The CLI uses the Ghost 6 Content API. By default, it requests the [maximum 100 posts](https://docs.ghost.org/content-api/parameters#limit) at a time, follows Ghost's [`meta.pagination.next`](https://docs.ghost.org/content-api/pagination) value, and pauses briefly between pages until the full site has been fetched.
4
6
 
5
7
  ## Install
6
8
 
7
- `npm install @tryghost/algolia --save`
9
+ Add the CLI to a project:
8
10
 
9
- or
11
+ ```sh
12
+ npm install @tryghost/algolia
13
+ ```
10
14
 
11
- `yarn add @tryghost/algolia`
15
+ or
12
16
 
17
+ ```sh
18
+ pnpm add @tryghost/algolia
19
+ ```
13
20
 
14
21
  ## Usage
15
22
 
16
- To use the CLI, install the dependencies with `yarn install` or `npm install`.
23
+ Copy [`example.config.json`](example.config.json) to a local file such as `config.json`, then set the Ghost Content API and Algolia credentials. `indexSettings` contains the current defaults and can be customized or removed.
17
24
 
18
- Copy the existing `example.config.json` to e. g. `config.json` and replace the relevant values for Ghost and Algolia.
19
- `indexSettings` reflects the current default settings and can either be overwritten, or removed from the config file.
25
+ Configuration files contain secrets and should not be committed. Files matching `packages/algolia/config*.json` are ignored by this repository.
20
26
 
21
- To run the batch index, run
27
+ After installing the package in another project, run its binary through that project's package runner:
22
28
 
23
- ```bash
24
- yarn algolia index <pathToConfig> [options]
29
+ ```sh
30
+ npx algolia index config.json [options]
25
31
  ```
26
32
 
27
- ### Caveats
28
-
29
- The [Fragmenter](https://github.com/TryGhost/algolia/tree/master/packages/algolia-fragmenter) breaks down large HTML pieces into smaller chunks by its headings. Sometimes the fragment is still too big and Algolia will throw an error listing the post id that caused the large fragment. The post id can be used to get the post slug, which then can be excluded from the batch run like this:
33
+ From this repository, invoke the entrypoint directly:
30
34
 
31
- ```bash
32
- yarn algolia index <pathToConfig> -s post-slug-to-exclude,and-another-post-slug-to-exclude
35
+ ```sh
36
+ cd packages/algolia
37
+ node bin/cli.js index config.json [options]
33
38
  ```
34
39
 
35
- ### Flags
36
-
37
- - `pathToConfig`, needs to be the relative (from this package) path to the config JSON file that contains the Ghost and Algolia API keys and settings (see [usage](#usage) above)
40
+ ### Options
38
41
 
39
- - `-s, --skip`, takes a comma separated list of post slugs that need to be **excluded** from the index (see [caveats](#caveats) above)
42
+ - `pathToConfig` is the path, relative to the current directory, to the JSON configuration file.
43
+ - `-s, --skip` excludes a comma-separated list of post slugs from the index.
44
+ - `-V, --verbose` enables verbose output.
45
+ - `-l, --limit` makes a single request for 1 to 100 posts instead of fetching every page.
46
+ - `-p, --page` selects the page for that single request and requires `--limit`.
47
+ - `-sjs, --skipjsonslugs` currently controls only a log message about `ignore_slugs`; it does not control exclusion. Configured `ignore_slugs` are always excluded after posts are fetched.
40
48
 
41
- - `-V, --verbose`, switches on verbose mode, but there's not much too see here (yet)
42
- - `-l, --limit`, limit the amount of posts to receive. Default is 'all'
43
- - `-p --page`, define the page to fetch posts from. To be used in combination with `limit`.
44
- - `-sjs --skipjsonslugs`, uses a list of slugs in `config.json` to skip before they're uploaded. This method will request all data from Ghost and skip at the point it would normally upload to Algolia. If you're getting `414 Request-URI Too Large` errors using `-s`, this is the method to use.
49
+ ### Large fragments
45
50
 
46
- ## Develop
51
+ The [Fragmenter](../algolia-fragmenter/README.md) splits large HTML strings by heading. A fragment can still exceed Algolia's record-size limit; the resulting error includes the post ID. Resolve the ID to a slug and exclude it from the batch:
47
52
 
48
- This is a mono repository, managed with [lerna](https://lernajs.io/).
49
-
50
- Follow the instructions for the top-level repo.
51
- 1. `git clone` this repo & `cd` into it as usual
52
- 2. Run `yarn` to install top-level dependencies.
53
-
54
-
55
- ## Run
56
-
57
- - `yarn dev`
53
+ ```sh
54
+ npx algolia index config.json --skip post-slug,another-post-slug
55
+ ```
58
56
 
57
+ If a long `--skip` filter causes a `414 Request-URI Too Large` response, put the slugs in the configuration file's `ignore_slugs` array. The CLI always applies that array after fetching posts; `--skipjsonslugs` is not required and currently affects logging only.
59
58
 
60
- ## Test
59
+ ## Development
61
60
 
62
- - `yarn lint` run just eslint
63
- - `yarn test` run lint and tests
61
+ Install dependencies from the repository root with `pnpm install`. From the root, run this package's tests and lint checks with:
64
62
 
63
+ ```sh
64
+ pnpm --filter @tryghost/algolia test
65
+ ```
65
66
 
67
+ Run the full monorepo suite with `pnpm test`.
66
68
 
69
+ ---
67
70
 
68
- # Copyright & License
71
+ ## Copyright & License
69
72
 
70
- Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE).
73
+ Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
package/bin/cli.js CHANGED
@@ -3,6 +3,7 @@ const prettyCLI = require('@tryghost/pretty-cli');
3
3
  const ui = require('@tryghost/pretty-cli').ui;
4
4
  const fs = require('fs-extra');
5
5
  const utils = require('../lib/utils');
6
+ const {fetchPosts} = require('../lib/fetch-posts');
6
7
  const GhostContentAPI = require('@tryghost/content-api');
7
8
  const transforms = require('@tryghost/algolia-fragmenter');
8
9
  const IndexFactory = require('@tryghost/algolia-indexer');
@@ -14,7 +15,7 @@ prettyCLI.command({
14
15
  flags: 'index <pathToConfig>',
15
16
  desc: 'Run a batch index of all Ghost posts to Algolia',
16
17
  paramsDesc: ['Path to a valid config JSON file'],
17
- setup: (sywac) => {
18
+ setup: sywac => {
18
19
  sywac.boolean('-V --verbose', {
19
20
  defaultValue: false,
20
21
  desc: 'Show verbose output'
@@ -24,17 +25,33 @@ prettyCLI.command({
24
25
  desc: 'Comma separated list of post slugs to exclude from indexing'
25
26
  });
26
27
  sywac.number('-l --limit', {
27
- desc: 'Amount of posts we want to fetch from Ghost'
28
+ desc: 'Fetch one page containing 1 to 100 posts'
28
29
  });
29
30
  sywac.number('-p --page', {
30
- desc: 'Use page to navigate through posts when setting a limit'
31
+ desc: 'Select a page; requires --limit'
31
32
  });
32
33
  sywac.array('-sjs --skipjsonslugs', {
33
34
  defaultValue: false,
34
35
  desc: 'Exclude post slugs from config JSON file'
35
36
  });
37
+ sywac.check((argv, context) => {
38
+ if (
39
+ argv.limit !== undefined &&
40
+ (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100)
41
+ ) {
42
+ context.cliMessage('--limit must be an integer from 1 to 100.');
43
+ }
44
+
45
+ if (argv.page !== undefined && argv.limit === undefined) {
46
+ context.cliMessage('--page requires --limit.');
47
+ }
48
+
49
+ if (argv.page !== undefined && (!Number.isInteger(argv.page) || argv.page < 1)) {
50
+ context.cliMessage('--page must be a positive integer.');
51
+ }
52
+ });
36
53
  },
37
- run: async (argv) => {
54
+ run: async argv => {
38
55
  const mainTimer = Date.now();
39
56
  let context = {errors: [], posts: []};
40
57
 
@@ -56,31 +73,33 @@ prettyCLI.command({
56
73
  // 2. Fetch all posts from the Ghost instance
57
74
  try {
58
75
  const timer = Date.now();
59
- const params = {limit: 'all', include: 'tags,authors'};
76
+ const fetchOptions = {};
60
77
  const ghost = new GhostContentAPI({
61
78
  url: context.ghost.apiUrl,
62
79
  key: context.ghost.apiKey,
63
- version: 'canary'
80
+ version: 'v6.0'
64
81
  });
65
82
 
66
83
  if (argv.skip && argv.skip.length > 0) {
67
84
  const filterSlugs = argv.skip.join(',');
68
85
 
69
- params.filter = `slug:-[${filterSlugs}]`;
86
+ fetchOptions.filter = `slug:-[${filterSlugs}]`;
70
87
  }
71
88
 
72
- if (argv.limit) {
73
- params.limit = argv.limit;
89
+ if (argv.limit !== undefined) {
90
+ fetchOptions.limit = argv.limit;
74
91
  }
75
92
 
76
- ui.log.info(`Fetching ${params.limit} posts from Ghost...`);
93
+ ui.log.info(
94
+ `Fetching ${argv.limit === undefined ? 'all' : argv.limit} posts from Ghost...`
95
+ );
77
96
 
78
- if (argv.page) {
97
+ if (argv.page !== undefined) {
79
98
  ui.log.info(`...from page #${argv.page}.`);
80
- params.page = argv.page;
99
+ fetchOptions.page = argv.page;
81
100
  }
82
101
 
83
- context.posts = await ghost.posts.browse(params);
102
+ context.posts = await fetchPosts(ghost.posts.browse.bind(ghost.posts), fetchOptions);
84
103
 
85
104
  ui.log.info(`Done fetching posts in ${Date.now() - timer}ms.`);
86
105
  } catch (error) {
@@ -100,7 +119,10 @@ prettyCLI.command({
100
119
  ui.log.info(`Skipping the ${ignoreSlugsCount} slugs in ${argv.pathToConfig}`);
101
120
  }
102
121
 
103
- context.posts = transforms.transformToAlgoliaObject(context.posts, context.ignore_slugs);
122
+ context.posts = transforms.transformToAlgoliaObject(
123
+ context.posts,
124
+ context.ignore_slugs
125
+ );
104
126
 
105
127
  context.fragments = context.posts.reduce(transforms.fragmentTransformer, []);
106
128
 
@@ -132,7 +154,9 @@ prettyCLI.command({
132
154
 
133
155
  await index.save(context.fragments);
134
156
 
135
- ui.log.ok(`${context.fragments.length} Fragments successfully saved to Algolia index in ${Date.now() - timer}ms.`);
157
+ ui.log.ok(
158
+ `${context.fragments.length} Fragments successfully saved to Algolia index in ${Date.now() - timer}ms.`
159
+ );
136
160
  } catch (error) {
137
161
  context.errors.push(error);
138
162
  return ui.log.error('Error saving fragments', context.errors);
@@ -0,0 +1,73 @@
1
+ const DEFAULT_PAGE_SIZE = 100;
2
+ const PAGE_DELAY_MS = 100;
3
+
4
+ const waitBetweenPages = () => {
5
+ return new Promise(resolve => {
6
+ setTimeout(resolve, PAGE_DELAY_MS);
7
+ });
8
+ };
9
+
10
+ const getNextPage = posts => {
11
+ if (!posts.meta || !posts.meta.pagination || typeof posts.meta.pagination !== 'object') {
12
+ throw TypeError('Ghost returned posts without pagination metadata.');
13
+ }
14
+
15
+ const nextPage = posts.meta.pagination.next;
16
+
17
+ if (nextPage !== null && (!Number.isInteger(nextPage) || nextPage < 1)) {
18
+ throw TypeError('Ghost returned an invalid next page.');
19
+ }
20
+
21
+ return nextPage;
22
+ };
23
+
24
+ const validatePosts = posts => {
25
+ if (!Array.isArray(posts)) {
26
+ throw TypeError('Ghost returned posts in an invalid format.');
27
+ }
28
+ };
29
+
30
+ module.exports.fetchPosts = async (browsePosts, options = {}) => {
31
+ const hasExplicitLimit = options.limit !== undefined;
32
+ const params = {include: 'tags,authors'};
33
+
34
+ if (hasExplicitLimit) {
35
+ params.limit = options.limit;
36
+ if (options.page !== undefined) {
37
+ params.page = options.page;
38
+ }
39
+ } else {
40
+ params.limit = DEFAULT_PAGE_SIZE;
41
+ params.page = 1;
42
+ }
43
+
44
+ if (options.filter !== undefined) {
45
+ params.filter = options.filter;
46
+ }
47
+
48
+ const firstPage = await browsePosts(params);
49
+ validatePosts(firstPage);
50
+ const posts = [...firstPage];
51
+
52
+ if (hasExplicitLimit) {
53
+ return posts;
54
+ }
55
+
56
+ let nextPage = getNextPage(firstPage);
57
+ const fetchedPages = new Set([1]);
58
+
59
+ while (nextPage !== null) {
60
+ if (fetchedPages.has(nextPage)) {
61
+ throw TypeError('Ghost returned a repeated next page.');
62
+ }
63
+
64
+ fetchedPages.add(nextPage);
65
+ await waitBetweenPages();
66
+ const page = await browsePosts({...params, page: nextPage});
67
+ validatePosts(page);
68
+ posts.push(...page);
69
+ nextPage = getNextPage(page);
70
+ }
71
+
72
+ return posts;
73
+ };
package/lib/utils.js CHANGED
@@ -2,7 +2,9 @@ const errors = require('@tryghost/errors');
2
2
 
3
3
  module.exports.verifyConfig = ({ghost, algolia}) => {
4
4
  if (!ghost || !algolia) {
5
- throw new errors.BadRequestError({message: 'Config has the wrong format. Check `example.json` for reference.'});
5
+ throw new errors.BadRequestError({
6
+ message: 'Config has the wrong format. Check `example.json` for reference.'
7
+ });
6
8
  }
7
9
 
8
10
  // Check for all Ghost keys
@@ -12,11 +14,15 @@ module.exports.verifyConfig = ({ghost, algolia}) => {
12
14
 
13
15
  // Check for all Ghost keys
14
16
  if (!algolia.apiKey || !algolia.appId || !algolia.index) {
15
- throw new errors.BadRequestError({message: 'Algolia index, appId or apiKey are missing.'});
17
+ throw new errors.BadRequestError({
18
+ message: 'Algolia index, appId or apiKey are missing.'
19
+ });
16
20
  }
17
21
 
18
22
  if (algolia.indexSettings && Object.keys(algolia.indexSettings) < 1) {
19
- throw new errors.BadRequestError({message: 'Algolia indexSettings are empty. Please remove or provide own settings.'});
23
+ throw new errors.BadRequestError({
24
+ message: 'Algolia indexSettings are empty. Please remove or provide own settings.'
25
+ });
20
26
  }
21
27
 
22
28
  return;
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@tryghost/algolia",
3
- "version": "0.2.11",
4
- "repository": "https://github.com/TryGhost/algolia/tree/master/packages/algolia",
3
+ "version": "0.3.1",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/TryGhost/algolia.git",
7
+ "directory": "packages/algolia"
8
+ },
5
9
  "author": "Ghost Foundation",
6
10
  "license": "MIT",
11
+ "engines": {
12
+ "node": ">=24"
13
+ },
7
14
  "bin": {
8
15
  "algolia": "./bin/cli.js"
9
16
  },
10
- "scripts": {
11
- "dev": "algolia",
12
- "algolia": "algolia",
13
- "test": "NODE_ENV=testing mocha './test/**/*.test.js'",
14
- "lint": "eslint . --ext .js --cache",
15
- "posttest": "yarn lint"
16
- },
17
17
  "files": [
18
18
  "bin",
19
19
  "index.js",
@@ -22,18 +22,19 @@
22
22
  "publishConfig": {
23
23
  "access": "public"
24
24
  },
25
- "devDependencies": {
26
- "mocha": "10.2.0",
27
- "should": "13.2.3",
28
- "sinon": "17.0.0"
29
- },
30
25
  "dependencies": {
31
- "@tryghost/algolia-fragmenter": "^0.2.7",
32
- "@tryghost/algolia-indexer": "^0.3.1",
33
- "@tryghost/content-api": "1.11.17",
34
- "@tryghost/errors": "1.2.26",
35
- "@tryghost/pretty-cli": "1.2.38",
36
- "fs-extra": "11.1.1"
26
+ "@tryghost/content-api": "1.12.10",
27
+ "@tryghost/errors": "3.3.7",
28
+ "@tryghost/pretty-cli": "3.3.7",
29
+ "fs-extra": "11.4.0",
30
+ "@tryghost/algolia-fragmenter": "^0.2.9",
31
+ "@tryghost/algolia-indexer": "^0.3.3"
37
32
  },
38
- "gitHead": "0f5dd82ecdabe8ae3d7153c7476472b08ce8732a"
39
- }
33
+ "scripts": {
34
+ "dev": "algolia",
35
+ "algolia": "algolia",
36
+ "test": "NODE_ENV=testing vitest run --root .",
37
+ "lint": "oxlint --quiet . && oxfmt --check .",
38
+ "posttest": "pnpm lint"
39
+ }
40
+ }