@tryghost/algolia-fragmenter 0.2.10 → 0.3.0

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/README.md CHANGED
@@ -19,14 +19,18 @@ pnpm add @tryghost/algolia-fragmenter
19
19
  Convert Ghost Content API posts, then reduce the resulting records into fragments:
20
20
 
21
21
  ```js
22
- const transforms = require('@tryghost/algolia-fragmenter');
22
+ import {fragmentTransformer, transformToAlgoliaObject} from '@tryghost/algolia-fragmenter';
23
23
 
24
- const records = transforms.transformToAlgoliaObject(posts);
25
- const fragments = records.reduce(transforms.fragmentTransformer, []);
24
+ const records = transformToAlgoliaObject(posts);
25
+ const fragments = records.reduce(fragmentTransformer, []);
26
26
  ```
27
27
 
28
28
  `transformToAlgoliaObject` accepts an optional array of post slugs to exclude as its second argument. `fragmentTransformer` is designed to be passed directly to `Array#reduce`.
29
29
 
30
+ Both operations are deprecated compatibility wrappers. They remain available with their existing output while a deeper record-building API is introduced separately.
31
+
32
+ This package is ESM-only and requires Node.js 24 or later.
33
+
30
34
  ## Development
31
35
 
32
36
  Install dependencies from the repository root with `pnpm install`. From the root, run this package's tests and lint checks with:
@@ -0,0 +1,16 @@
1
+ export type GhostContent = Readonly<Record<string, unknown>>;
2
+ export type AlgoliaRecord = Record<string, unknown>;
3
+ /**
4
+ * @deprecated Retained for compatibility while the deep record-building API is introduced.
5
+ */
6
+ export declare const fragmentTransformer: (recordAccumulator: AlgoliaRecord[], ghostContent: AlgoliaRecord) => AlgoliaRecord[];
7
+ /**
8
+ * @deprecated Retained for compatibility while the deep record-building API is introduced.
9
+ */
10
+ export declare const transformToAlgoliaObject: (posts: readonly GhostContent[], ignoreSlugs?: readonly string[]) => AlgoliaRecord[];
11
+ declare const _default: {
12
+ fragmentTransformer: typeof fragmentTransformer;
13
+ transformToAlgoliaObject: typeof transformToAlgoliaObject;
14
+ };
15
+ export default _default;
16
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAEA,MAAM,MAAM,YAAY,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAE7D,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAoEpD;;GAEG;AACH,eAAO,MAAM,mBAAmB,sBACT,aAAa,EAAE,gBACpB,aAAa,KAC5B,aAAa,EASf,CAAC;AAqBF;;GAEG;AACH,eAAO,MAAM,wBAAwB,UAC1B,SAAS,YAAY,EAAE,gBAChB,SAAS,MAAM,EAAE,KAChC,aAAa,EAqBf,CAAC"}
package/lib/index.mjs ADDED
@@ -0,0 +1,80 @@
1
+ import { extract } from '@tryghost/algolia-html-extractor';
2
+ const createLegacyFragment = (fragment) => ({
3
+ html: fragment.html,
4
+ content: fragment.text,
5
+ headings: [...fragment.headingPath],
6
+ anchor: fragment.anchor,
7
+ sourceTag: fragment.sourceTag,
8
+ customRanking: {
9
+ position: fragment.position,
10
+ heading: fragment.headingRank
11
+ }
12
+ });
13
+ const reduceFragmentsUnderHeadings = (groups, fragment) => {
14
+ const existingGroup = groups.find(group => group.anchor === fragment.anchor);
15
+ if (existingGroup === undefined) {
16
+ groups.push(fragment);
17
+ return groups;
18
+ }
19
+ existingGroup.html += fragment.sourceTag === 'pre' ? ` ${fragment.content}` : fragment.html;
20
+ existingGroup.content += ` ${fragment.content}`;
21
+ return groups;
22
+ };
23
+ const toAlgoliaRecord = (ghostContent, fragment, index) => {
24
+ const { content: _content, sourceTag: _sourceTag, ...groupedFragment } = fragment;
25
+ const url = fragment.anchor === null ? ghostContent.url : `${ghostContent.url}#${fragment.anchor}`;
26
+ return {
27
+ ...ghostContent,
28
+ ...groupedFragment,
29
+ url,
30
+ objectID: `${ghostContent.objectID}_${index}`
31
+ };
32
+ };
33
+ /**
34
+ * @deprecated Retained for compatibility while the deep record-building API is introduced.
35
+ */
36
+ export const fragmentTransformer = (recordAccumulator, ghostContent) => {
37
+ const groupedFragments = extract(ghostContent.html)
38
+ .map(createLegacyFragment)
39
+ .reduce(reduceFragmentsUnderHeadings, []);
40
+ const records = groupedFragments.map((fragment, index) => toAlgoliaRecord(ghostContent, fragment, index));
41
+ return [...recordAccumulator, ...records];
42
+ };
43
+ const projectLegacyRelations = (value, fieldName) => {
44
+ const relations = value;
45
+ if (!relations?.length) {
46
+ return [];
47
+ }
48
+ if (typeof relations.forEach !== 'function') {
49
+ throw new TypeError(`post.${fieldName}.forEach is not a function`);
50
+ }
51
+ const projected = [];
52
+ relations.forEach(relation => {
53
+ projected.push({ name: relation.name, slug: relation.slug });
54
+ });
55
+ return projected;
56
+ };
57
+ /**
58
+ * @deprecated Retained for compatibility while the deep record-building API is introduced.
59
+ */
60
+ export const transformToAlgoliaObject = (posts, ignoreSlugs) => {
61
+ const algoliaObjects = [];
62
+ for (const post of posts) {
63
+ if (ignoreSlugs?.some(slug => slug === post.slug)) {
64
+ continue;
65
+ }
66
+ algoliaObjects.push({
67
+ objectID: post.id,
68
+ slug: post.slug,
69
+ url: post.url,
70
+ html: post.html,
71
+ image: post.feature_image,
72
+ title: post.title,
73
+ tags: projectLegacyRelations(post.tags, 'tags'),
74
+ authors: projectLegacyRelations(post.authors, 'authors')
75
+ });
76
+ }
77
+ return algoliaObjects;
78
+ };
79
+ export default { fragmentTransformer, transformToAlgoliaObject };
80
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"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","sourcesContent":["import {extract, type ExtractedTagName, type HeadingRank} from '@tryghost/algolia-html-extractor';\n\nexport type GhostContent = Readonly<Record<string, unknown>>;\n\nexport type AlgoliaRecord = Record<string, unknown>;\n\ntype GhostRelation = Readonly<Record<string, unknown>>;\n\ntype LegacyRelationCollection = {\n length: number;\n forEach(callback: (relation: GhostRelation) => void): void;\n};\n\ntype GroupedFragment = {\n html: string;\n headings: string[];\n anchor: string | null;\n customRanking: {\n position: number;\n heading: HeadingRank;\n };\n};\n\ntype LegacyFragment = GroupedFragment & {\n content: string;\n sourceTag: ExtractedTagName;\n};\n\nconst createLegacyFragment = (fragment: ReturnType<typeof extract>[number]): LegacyFragment => ({\n html: fragment.html,\n content: fragment.text,\n headings: [...fragment.headingPath],\n anchor: fragment.anchor,\n sourceTag: fragment.sourceTag,\n customRanking: {\n position: fragment.position,\n heading: fragment.headingRank\n }\n});\n\nconst reduceFragmentsUnderHeadings = (\n groups: LegacyFragment[],\n fragment: LegacyFragment\n): LegacyFragment[] => {\n const existingGroup = groups.find(group => group.anchor === fragment.anchor);\n if (existingGroup === undefined) {\n groups.push(fragment);\n return groups;\n }\n\n existingGroup.html += fragment.sourceTag === 'pre' ? ` ${fragment.content}` : fragment.html;\n existingGroup.content += ` ${fragment.content}`;\n return groups;\n};\n\nconst toAlgoliaRecord = (\n ghostContent: AlgoliaRecord,\n fragment: LegacyFragment,\n index: number\n): AlgoliaRecord => {\n const {content: _content, sourceTag: _sourceTag, ...groupedFragment} = fragment;\n const url =\n fragment.anchor === null ? ghostContent.url : `${ghostContent.url}#${fragment.anchor}`;\n\n return {\n ...ghostContent,\n ...groupedFragment,\n url,\n objectID: `${ghostContent.objectID}_${index}`\n };\n};\n\n/**\n * @deprecated Retained for compatibility while the deep record-building API is introduced.\n */\nexport const fragmentTransformer = (\n recordAccumulator: AlgoliaRecord[],\n ghostContent: AlgoliaRecord\n): AlgoliaRecord[] => {\n const groupedFragments = extract(ghostContent.html as string)\n .map(createLegacyFragment)\n .reduce(reduceFragmentsUnderHeadings, []);\n const records = groupedFragments.map((fragment, index) =>\n toAlgoliaRecord(ghostContent, fragment, index)\n );\n\n return [...recordAccumulator, ...records];\n};\n\nconst projectLegacyRelations = (\n value: unknown,\n fieldName: 'tags' | 'authors'\n): Array<{name: unknown; slug: unknown}> => {\n const relations = value as Partial<LegacyRelationCollection> | null | undefined;\n if (!relations?.length) {\n return [];\n }\n if (typeof relations.forEach !== 'function') {\n throw new TypeError(`post.${fieldName}.forEach is not a function`);\n }\n\n const projected: Array<{name: unknown; slug: unknown}> = [];\n relations.forEach(relation => {\n projected.push({name: relation.name, slug: relation.slug});\n });\n return projected;\n};\n\n/**\n * @deprecated Retained for compatibility while the deep record-building API is introduced.\n */\nexport const transformToAlgoliaObject = (\n posts: readonly GhostContent[],\n ignoreSlugs?: readonly string[]\n): AlgoliaRecord[] => {\n const algoliaObjects: AlgoliaRecord[] = [];\n\n for (const post of posts) {\n if (ignoreSlugs?.some(slug => slug === post.slug)) {\n continue;\n }\n\n algoliaObjects.push({\n objectID: post.id,\n slug: post.slug,\n url: post.url,\n html: post.html,\n image: post.feature_image,\n title: post.title,\n tags: projectLegacyRelations(post.tags, 'tags'),\n authors: projectLegacyRelations(post.authors, 'authors')\n });\n }\n\n return algoliaObjects;\n};\n\nexport default {fragmentTransformer, transformToAlgoliaObject};\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryghost/algolia-fragmenter",
3
- "version": "0.2.10",
3
+ "version": "0.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/TryGhost/algolia.git",
@@ -11,21 +11,35 @@
11
11
  "engines": {
12
12
  "node": ">=24"
13
13
  },
14
- "main": "index.js",
14
+ "type": "module",
15
+ "types": "./lib/index.d.mts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/index.d.mts",
19
+ "import": "./lib/index.mjs"
20
+ }
21
+ },
15
22
  "files": [
16
- "index.js",
17
23
  "lib"
18
24
  ],
19
25
  "publishConfig": {
20
26
  "access": "public"
21
27
  },
22
28
  "dependencies": {
23
- "algolia-html-extractor": "0.0.1"
29
+ "@tryghost/algolia-html-extractor": "^0.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "24.13.3",
33
+ "typescript": "7.0.2"
24
34
  },
25
35
  "scripts": {
26
36
  "dev": "echo \"Implement me!\"",
37
+ "prebuild": "pnpm --filter @tryghost/algolia-html-extractor build",
38
+ "pretest": "pnpm build",
27
39
  "test": "NODE_ENV=testing vitest run --root .",
40
+ "typecheck": "tsc --project tsconfig.json",
41
+ "build": "tsc --project tsconfig.build.json",
28
42
  "lint": "oxlint --quiet . && oxfmt --check .",
29
- "posttest": "pnpm lint"
43
+ "posttest": "pnpm typecheck && pnpm lint"
30
44
  }
31
45
  }
package/index.js DELETED
@@ -1 +0,0 @@
1
- module.exports = require('./lib/transformer');
@@ -1,116 +0,0 @@
1
- const HtmlExtractor = require(`algolia-html-extractor`);
2
- const Extractor = new HtmlExtractor();
3
-
4
- /**
5
- * Utility function, takes the output of HTML Extractor, and reduces it back down
6
- * So that there is a group of HTML/content per heading
7
- *
8
- * @param {Array} accumulator
9
- * @param {Object} fragment
10
- */
11
- const reduceFragmentsUnderHeadings = (accumulator, fragment) => {
12
- const existingFragment = accumulator.find(existing => existing.anchor === fragment.anchor);
13
-
14
- if (existingFragment) {
15
- // Merge our fragments together
16
- if (fragment.node && fragment.node.tagName === `PRE`) {
17
- // For pre-tags, we don't keep all the markup
18
- existingFragment.html += ` ${fragment.content}`; // keep a space
19
- existingFragment.content += ` ${fragment.content}`; // keep a space
20
- } else {
21
- existingFragment.html += fragment.html;
22
- existingFragment.content += ` ${fragment.content}`; // keep a space
23
- }
24
- } else {
25
- // If we don't already have a matching fragment with this anchor, add it
26
- accumulator.push(fragment);
27
- }
28
-
29
- return accumulator;
30
- };
31
-
32
- /**
33
- * Fragment Transformer
34
- * breaks down large HTML strings into sensible fragments based on headings
35
- */
36
- module.exports.fragmentTransformer = (recordAccumulator, node) => {
37
- let htmlFragments = Extractor
38
- // These are the top-level HTML elements that we keep - this results in a lot of fragments
39
- .run(node.html, {cssSelector: `p,pre,td,li`})
40
- // Use the utility function to merge fragments so that there is one-per-heading
41
- .reduce(reduceFragmentsUnderHeadings, []);
42
-
43
- // convert our fragments for this node into valid objects, and merge int the
44
- const records = htmlFragments.reduce((fragmentAccumulator, fragment, index) => {
45
- // Don't need a reference to the html node type
46
- delete fragment.node;
47
- // For now at least, we're not going to index the content string
48
- // The HTML string is already very long, and there are size limits
49
- delete fragment.content;
50
- // If we have an anchor, change the URL to be a deep link
51
- if (fragment.anchor) {
52
- fragment.url = `${node.url}#${fragment.anchor}`;
53
- }
54
-
55
- let objectID = `${node.objectID}_${index}`;
56
-
57
- // TODO: switch this on in verbose mode only
58
- // // If fragments are too long, we need this to see which fragment it was
59
-
60
- return [...fragmentAccumulator, {...node, ...fragment, objectID: objectID}];
61
- }, []);
62
-
63
- return [...recordAccumulator, ...records];
64
- };
65
-
66
- module.exports._testReduceFragmentsUnderHeadings = reduceFragmentsUnderHeadings;
67
-
68
- /**
69
- * Algolia Object Transformer
70
- * takes a Ghost post and selects the properties needed to send to Algolia
71
- *
72
- * @param {Array} posts
73
- */
74
- module.exports.transformToAlgoliaObject = (posts, ignoreSlugs) => {
75
- const algoliaObjects = [];
76
-
77
- posts.map(post => {
78
- // Define the properties we need for Algolia
79
- const algoliaPost = {
80
- objectID: post.id,
81
- slug: post.slug,
82
- url: post.url,
83
- html: post.html,
84
- image: post.feature_image,
85
- title: post.title,
86
- tags: [],
87
- authors: []
88
- };
89
-
90
- // If we have an array of slugs to ignore, and the current
91
- // post slug is in that list, skip this loop iteration
92
- if (ignoreSlugs) {
93
- if (ignoreSlugs.includes(post.slug)) {
94
- return false;
95
- }
96
- }
97
-
98
- if (post.tags && post.tags.length) {
99
- post.tags.forEach(tag => {
100
- algoliaPost.tags.push({name: tag.name, slug: tag.slug});
101
- });
102
- }
103
-
104
- if (post.authors && post.authors.length) {
105
- post.authors.forEach(author => {
106
- algoliaPost.authors.push({name: author.name, slug: author.slug});
107
- });
108
- }
109
-
110
- algoliaObjects.push(algoliaPost);
111
-
112
- return algoliaPost;
113
- });
114
-
115
- return algoliaObjects;
116
- };