Skip to content

Commit fc73b86

Browse files
committed
Migrated packages from Ghost repo
ref https://linear.app/ghost/issue/ENG-2391/html-to-plaintext ref https://linear.app/ghost/issue/ENG-2392/members-csv - this commit extract 2 packages from the Ghost repo as we're depackaging the monorepo and extracting packages where possible
2 parents 60de340 + 5a506ed commit fc73b86

24 files changed

+857
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
plugins: ['ghost'],
3+
extends: [
4+
'plugin:ghost/node'
5+
]
6+
};

packages/html-to-plaintext/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Html To Plaintext
2+
3+
4+
## Usage
5+
6+
7+
## Develop
8+
9+
This is a monorepo package.
10+
11+
Follow the instructions for the top-level repo.
12+
1. `git clone` this repo & `cd` into it as usual
13+
2. Run `yarn` to install top-level dependencies.
14+
15+
16+
17+
## Test
18+
19+
- `yarn lint` run just eslint
20+
- `yarn test` run lint and tests
21+

packages/html-to-plaintext/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('./lib/html-to-plaintext');
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
const _ = require('lodash');
2+
3+
const mergeSettings = (extraSettings) => {
4+
return _.mergeWith({}, baseSettings, extraSettings, function customizer(objValue, srcValue) {
5+
if (_.isArray(objValue)) {
6+
return objValue.concat(srcValue);
7+
}
8+
});
9+
};
10+
11+
const baseSettings = {
12+
wordwrap: false,
13+
preserveNewlines: true,
14+
15+
// equiv returnDomByDefault: true,
16+
baseElements: {returnDomByDefault: true},
17+
selectors: [
18+
// Ignore images, equiv ignoreImage: true
19+
{selector: 'img', format: 'skip'},
20+
21+
// disable uppercase headings, equiv uppercaseHeadings: false
22+
{selector: 'h1', options: {uppercase: false}},
23+
{selector: 'h2', options: {uppercase: false}},
24+
{selector: 'h3', options: {uppercase: false}},
25+
{selector: 'h4', options: {uppercase: false}},
26+
{selector: 'h5', options: {uppercase: false}},
27+
{selector: 'h6', options: {uppercase: false}},
28+
{selector: 'table', options: {uppercaseHeaderCells: false}},
29+
30+
// Backwards compatibility with html-to-text 5.1.1
31+
{selector: 'div', format: 'inline'}
32+
]
33+
};
34+
35+
let excerptConverter;
36+
let emailConverter;
37+
let commentConverter;
38+
let commentSnippetConverter;
39+
40+
const loadConverters = () => {
41+
if (excerptConverter && emailConverter) {
42+
return;
43+
}
44+
45+
const {compile} = require('html-to-text');
46+
47+
const excerptSettings = mergeSettings({
48+
selectors: [
49+
{selector: 'a', options: {ignoreHref: true}},
50+
{selector: 'figcaption', format: 'skip'},
51+
// Strip inline and bottom footnotes
52+
{selector: 'a[rel=footnote]', format: 'skip'},
53+
{selector: 'div.footnotes', format: 'skip'},
54+
// Don't output hrs
55+
{selector: 'hr', format: 'skip'},
56+
// Don't output > in blockquotes
57+
{selector: 'blockquote', format: 'block'},
58+
// Don't include signup cards in excerpts
59+
{selector: '.kg-signup-card', format: 'skip'}
60+
]
61+
});
62+
63+
const emailSettings = mergeSettings({
64+
selectors: [
65+
// equiv hideLinkHrefIfSameAsText: true
66+
{selector: 'a', options: {hideLinkHrefIfSameAsText: true}},
67+
// Don't include html .preheader in email
68+
{selector: '.preheader', format: 'skip'}
69+
]
70+
});
71+
72+
const commentSettings = mergeSettings({
73+
preserveNewlines: false,
74+
selectors: [
75+
// equiv hideLinkHrefIfSameAsText: true
76+
{selector: 'a', options: {hideLinkHrefIfSameAsText: true}},
77+
// No space between <p> tags. An empty <p> is needed
78+
{selector: 'p', options: {leadingLineBreaks: 1, trailingLineBreaks: 1}}
79+
]
80+
});
81+
82+
const commentSnippetSettings = mergeSettings({
83+
preserveNewlines: false,
84+
ignoreHref: true,
85+
selectors: [
86+
{selector: 'blockquote', format: 'skip'}
87+
]
88+
});
89+
90+
excerptConverter = compile(excerptSettings);
91+
emailConverter = compile(emailSettings);
92+
commentConverter = compile(commentSettings);
93+
commentSnippetConverter = compile(commentSnippetSettings);
94+
};
95+
96+
module.exports.excerpt = (html) => {
97+
loadConverters();
98+
99+
return excerptConverter(html);
100+
};
101+
102+
module.exports.email = (html) => {
103+
loadConverters();
104+
105+
return emailConverter(html);
106+
};
107+
108+
module.exports.comment = (html) => {
109+
loadConverters();
110+
111+
return commentConverter(html);
112+
};
113+
114+
module.exports.commentSnippet = (html) => {
115+
loadConverters();
116+
117+
return commentSnippetConverter(html)
118+
.replace(/\n/g, ' ')
119+
.replace(/\s+/g, ' ');
120+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@tryghost/html-to-plaintext",
3+
"version": "0.0.0",
4+
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/html-to-plaintext",
5+
"author": "Ghost Foundation",
6+
"private": true,
7+
"main": "index.js",
8+
"files": [
9+
"index.js",
10+
"lib"
11+
],
12+
"scripts": {
13+
"dev": "echo \"Implement me!\"",
14+
"test:unit": "NODE_ENV=testing c8 --all --check-coverage --reporter text --reporter cobertura -- mocha --reporter dot './test/**/*.test.js'",
15+
"test": "yarn test:unit",
16+
"lint:code": "eslint *.js lib/ --ext .js --cache",
17+
"lint": "yarn lint:code && yarn lint:test",
18+
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
19+
},
20+
"devDependencies": {
21+
"c8": "8.0.1",
22+
"mocha": "10.8.2"
23+
},
24+
"dependencies": {
25+
"html-to-text": "8.2.1",
26+
"lodash": "4.17.21"
27+
}
28+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
plugins: ['ghost'],
3+
extends: [
4+
'plugin:ghost/test'
5+
]
6+
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
const assert = require('assert/strict');
2+
const htmlToPlaintext = require('../');
3+
4+
describe('Html to Plaintext', function () {
5+
function getEmailandExcert(input) {
6+
const excerpt = htmlToPlaintext.excerpt(input);
7+
const email = htmlToPlaintext.email(input);
8+
9+
return {email, excerpt};
10+
}
11+
12+
describe('excerpt vs email behavior', function () {
13+
it('example case with img & link', function () {
14+
const input = '<p>Some thing <a href="https://google.com">Google</a> once told me.</p><img src="https://hotlink.com" alt="An important image"><p>And <strong>another</strong> thing.</p>';
15+
16+
const {excerpt, email} = getEmailandExcert(input);
17+
18+
assert.equal(excerpt, 'Some thing Google once told me.\n\nAnd another thing.');
19+
assert.equal(email, 'Some thing Google [https://google.com] once told me.\n\nAnd another thing.');
20+
});
21+
22+
it('example case with figure + figcaption', function () {
23+
const input = '<figcaption>A snippet from a post template</figcaption></figure><p>See? Not that scary! But still completely optional. </p>';
24+
25+
const {excerpt, email} = getEmailandExcert(input);
26+
27+
assert.equal(excerpt, 'See? Not that scary! But still completely optional.');
28+
assert.equal(email, 'A snippet from a post template\n\nSee? Not that scary! But still completely optional.');
29+
});
30+
31+
it('example case with figure + figcaption inside a link', function () {
32+
const input = '<a href="https://mysite.com"><figcaption>A snippet from a post template</figcaption></figure></a><p>See? Not that scary! But still completely optional. </p>';
33+
34+
const {excerpt, email} = getEmailandExcert(input);
35+
36+
assert.equal(excerpt, 'See? Not that scary! But still completely optional.');
37+
assert.equal(email, 'A snippet from a post template [https://mysite.com]\n\nSee? Not that scary! But still completely optional.');
38+
});
39+
40+
it('longer example', function () {
41+
const input = '<p>As discussed in the <a href="https://demo.ghost.io/welcome/">introduction</a> post, one of the best things about Ghost is just how much you can customize to turn your site into something unique. Everything about your layout and design can be changed, so you\'re not stuck with yet another clone of a social network profile.</p><p>How far you want to go with customization is completely up to you, there\'s no right or wrong approach! The majority of people use one of Ghost\'s built-in themes to get started, and then progress to something more bespoke later on as their site grows. </p><p>The best way to get started is with Ghost\'s branding settings, where you can set up colors, images and logos to fit with your brand.</p><figure class="kg-card kg-image-card kg-width-wide kg-card-hascaption"><img src="https://static.ghost.org/v4.0.0/images/brandsettings.png" class="kg-image" alt loading="lazy" width="3456" height="2338"><figcaption>Ghost Admin → Settings → Branding</figcaption></figure><p>Any Ghost theme that\'s up to date and compatible with Ghost 4.0 and higher will reflect your branding settings in the preview window, so you can see what your site will look like as you experiment with different options.</p><p>When selecting an accent color, try to choose something which will contrast well with white text. Many themes will use your accent color as the background for buttons, headers and navigational elements. Vibrant colors with a darker hue tend to work best, as a general rule.</p><h2 id="installing-ghost-themes">Installing Ghost themes</h2><p>By default, new sites are created with Ghost\'s friendly publication theme, called Casper. Everything in Casper is optimized to work for the most common types of blog, newsletter and publication that people create with Ghost — so it\'s a perfect place to start.</p><p>However, there are hundreds of different themes available to install, so you can pick out a look and feel that suits you best.</p><figure class="kg-card kg-image-card kg-width-wide kg-card-hascaption"><img src="https://static.ghost.org/v4.0.0/images/themesettings.png" class="kg-image" alt loading="lazy" width="3208" height="1618"><figcaption>Ghost Admin → Settings → Theme</figcaption></figure><p>Inside Ghost\'s theme settings you\'ll find 4 more official themes that can be directly installed and activated. Each theme is suited to slightly different use-cases.</p><ul><li><strong>Casper</strong> <em>(default)</em> — Made for all sorts of blogs and newsletters</li><li><strong>Edition</strong> — A beautiful minimal template for newsletter authors</li><li><strong>Alto</strong> — A slick news/magazine style design for creators</li><li><strong>London</strong> — A light photography theme with a bold grid</li><li><strong>Ease</strong> — A library theme for organizing large content archives</li></ul><p>And if none of those feel quite right, head on over to the <a href="https://ghost.org/themes/">Ghost Marketplace</a>, where you\'ll find a huge variety of both free and premium themes.</p><h2 id="building-something-custom">Building something custom</h2><p>Finally, if you want something completely bespoke for your site, you can always build a custom theme from scratch and upload it to your site.</p><p>Ghost\'s theming template files are very easy to work with, and can be picked up in the space of a few hours by anyone who has just a little bit of knowledge of HTML and CSS. Templates from other platforms can also be ported to Ghost with relatively little effort.</p><p>If you want to take a quick look at the theme syntax to see what it\'s like, you can <a href="https://github.com/tryghost/casper/">browse through the files of the default Casper theme</a>. We\'ve added tons of inline code comments to make it easy to learn, and the structure is very readable.</p><figure class="kg-card kg-code-card"><pre><code class="language-handlebars">{{#post}}\n&lt;article class="article {{post_class}}"&gt;\n\n &lt;h1&gt;{{title}}&lt;/h1&gt;\n \n {{#if feature_image}}\n \t&lt;img src="{{feature_image}}" alt="Feature image" /&gt;\n {{/if}}\n \n {{content}}\n\n&lt;/article&gt;\n{{/post}}</code></pre><figcaption>A snippet from a post template</figcaption></figure><p>See? Not that scary! But still completely optional. </p><p>If you\'re interested in creating your own Ghost theme, check out our extensive <a href="https://ghost.org/docs/themes/">theme documentation</a> for a full guide to all the different template variables and helpers which are available.</p>';
42+
43+
const {excerpt, email} = getEmailandExcert(input);
44+
45+
// No link
46+
assert.doesNotMatch(excerpt, /https:\/\/demo\.ghost\.io\/welcome/);
47+
// No figcaption
48+
assert.doesNotMatch(excerpt, /Ghost Admin Settings Theme/);
49+
50+
// contains link
51+
assert.match(email, /https:\/\/demo\.ghost\.io\/welcome/);
52+
// contains figcaption
53+
assert.match(email, /Ghost Admin Settings Theme/);
54+
});
55+
});
56+
57+
describe('footnotes', function () {
58+
it('strips multiple inline footnotes', function () {
59+
const html = '<p>Testing<sup id="fnref:1"><a href="#fn:1" rel="footnote">1</a></sup>, my footnotes. And stuff. Footnote<sup id="fnref:2"><a href="#fn:2" rel="footnote">2</a></sup><a href="http://google.com">with a link</a> right after.';
60+
const expected = 'Testing, my footnotes. And stuff. Footnotewith a link right after.';
61+
const {excerpt} = getEmailandExcert(html);
62+
assert.equal(excerpt, expected);
63+
});
64+
65+
it('strips inline and bottom footnotes', function () {
66+
const html = '<p>Testing<sup id="fnref:1"><a href="#fn:1" rel="footnote">1</a></sup> a very short post with a single footnote.</p>\n' +
67+
'<div class="footnotes"><ol><li class="footnote" id="fn:1"><p><a href="https://ghost.org">https://ghost.org</a> <a href="#fnref:1" title="return to article">↩</a></p></li></ol></div>';
68+
const expected = 'Testing a very short post with a single footnote.\n';
69+
const {excerpt} = getEmailandExcert(html);
70+
assert.equal(excerpt, expected);
71+
});
72+
});
73+
74+
describe('Special cases', function () {
75+
it('Instagram (blockquotes)', function () {
76+
// This is an instagram embed, but with all the style attributes & svg content removed for brevity
77+
const html = '<p>Some text in a paragraph.</p><!--kg-card-begin: html--><blockquote class="instagram-media" data-instgrm-captioned data-instgrm-permalink="https://www.instagram.com/p/AbC123dEf/?utm_source=ig_embed&amp;utm_campaign=loading" data-instgrm-version="14"><div> <a href="https://www.instagram.com/p/AbC123dEf/?utm_source=ig_embed&amp;utm_campaign=loading" target="_blank"><div><div></div><div><div></div><div></div></div></div><div></div><div><svg width="50px" height="50px" viewBox="0 0 60 60" version="1.1" xmlns="https://www.w3.org/2000/svg" xmlns:xlink="https://www.w3.org/1999/xlink"><!-- svg stuff --></svg></div><div><div>View this post on Instagram</div></div><div></div><div><div><div></div><div></div><div></div></div><div><div></div><div><div></div><div></div><div></div></div></div><div><div></div><div></div></div></a><p><a href="https://www.instagram.com/p/AbC123dEf/?utm_source=ig_embed&amp;utm_campaign=loading" target="_blank">A post shared by Some Dude (@somedude)</a></p></div></blockquote><script async src="//www.instagram.com/embed.js"></script><!--kg-card-end: html-->';
78+
const expected = 'Some text in a paragraph.\n\nView this post on Instagram\n\nA post shared by Some Dude (@somedude)';
79+
const {excerpt} = getEmailandExcert(html);
80+
assert.equal(excerpt, expected);
81+
});
82+
83+
it('HRs', function () {
84+
const html = '<p>See you later alligator...</p><hr><p>...in a while crocodile</p>';
85+
const expected = 'See you later alligator...\n\n...in a while crocodile';
86+
const {excerpt} = getEmailandExcert(html);
87+
assert.equal(excerpt, expected);
88+
});
89+
});
90+
91+
describe('commentSnippet converter', function () {
92+
function testConverter({input, expected}) {
93+
return () => {
94+
const output = htmlToPlaintext.commentSnippet(input);
95+
assert.equal(output, expected);
96+
};
97+
}
98+
99+
it('skips href urls', testConverter({
100+
input: '<a href="https://mysite.com">A snippet from a post template</a>',
101+
expected: 'A snippet from a post template'
102+
}));
103+
104+
it('skips blockquotes', testConverter({
105+
input: '<blockquote>Previous comment quote</blockquote><p>And the new comment text</p>',
106+
expected: 'And the new comment text'
107+
}));
108+
109+
it('returns a single line', testConverter({
110+
input: '<p>First paragraph.</p><p>Second paragraph.</p>',
111+
expected: 'First paragraph. Second paragraph.'
112+
}));
113+
});
114+
});

packages/members-csv/.eslintrc.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
plugins: ['ghost'],
3+
extends: [
4+
'plugin:ghost/node'
5+
]
6+
};

packages/members-csv/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Members Csv
2+
3+
## Usage
4+
There are 2 parts to this package: CSV to JSON serialization and JSON to CSV serialization. The module exposes 2 methods to fulfil these: `parse` and `unparse` respectively.
5+
6+
To `parse` CSV file and convert it to JSON use `parse` method, e.g.:
7+
```js
8+
const {parse} = require('@tryghost/members-csv');
9+
10+
const mapping = {
11+
email: 'csv_column_containing_email_data',
12+
name: 'csv_column_containing_names_data'
13+
}
14+
const membersJSON = await parse(csvFilePath, mapping);
15+
```
16+
17+
`csvFilePath` - is a path to the CSV file that has to be processed
18+
`mapping` - optional parameter, it's a hash describing custom mapping for CSV columns to JSON properties
19+
20+
Example mapping for CSV having email under `correo_electronico` column would look like following:
21+
```
22+
{
23+
email: 'correo_electronico'
24+
}
25+
```
26+
27+
To `unparse` JSON to CSV compatible with members format use following:
28+
```js
29+
const {unparse} = require('@tryghost/members-csv');
30+
31+
const members = [{
32+
33+
name: 'Sam Memberino',
34+
note: 'Early supporter'
35+
}];
36+
37+
const membersCSV = unparse(members);
38+
39+
console.log(membersCSV);
40+
// -> "id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels\r\n,[email protected],Sam Memberino,Early supporter,,,,,,"
41+
```

packages/members-csv/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
module.exports.parse = require('./lib/parse');
2+
module.exports.unparse = require('./lib/unparse');

0 commit comments

Comments
 (0)