@bartificer/linkify 2.0.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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@bartificer/linkify",
3
+ "version": "2.0.0",
4
+ "description": "An module for converting URLs into pretty links in any format.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "main": "dist/index.js",
10
+ "exports": {
11
+ ".": "./dist/index.js"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "scripts": {
17
+ "build": "webpack",
18
+ "publish": "npm run build && npm publish"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/bartificer/linkify.git"
23
+ },
24
+ "author": "Bartificer Creations Ltd. <opensource@bartificer.ie>",
25
+ "license": "MIT",
26
+ "bugs": {
27
+ "url": "https://github.com/bartificer/linkify/issues"
28
+ },
29
+ "homepage": "https://github.com/bartificer/linkify#readme",
30
+ "dependencies": {
31
+ "cheerio": "^1.0.0",
32
+ "clipboardy": "^5.3.1",
33
+ "mustache": "^4.2.0",
34
+ "node-fetch": "^3.3.2",
35
+ "urijs": "^1.19.10",
36
+ "url-slug": "^5.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "webpack": "^5.105.4",
40
+ "webpack-cli": "^7.0.2"
41
+ }
42
+ }
@@ -0,0 +1,135 @@
1
+ import {default as URI} from 'urijs';
2
+
3
+ export class LinkData {
4
+ /**
5
+ * This constructor throws a {@link ValidationError} unless a valid URL is passed.
6
+ *
7
+ * @param {URL} url - The link's URL.
8
+ * @param {string} [text] - The link's text, defaults to the URL.
9
+ * @param {string} [description] - A description for the link, defaults to
10
+ * the link text.
11
+ * @throws {ValidationError} A validation error is thrown if an invalid URL
12
+ * is passed.
13
+ */
14
+ constructor(url, text, description){
15
+ // TO DO - add validation
16
+
17
+ /**
18
+ * The link's URL as a URI.js object.
19
+ *
20
+ * @private
21
+ * @type {URIObject}
22
+ */
23
+ this._uri = URI();
24
+
25
+ /**
26
+ * The link text.
27
+ *
28
+ * @private
29
+ * @type {string}
30
+ */
31
+ this._text = '';
32
+
33
+ /**
34
+ * The link description.
35
+ *
36
+ * @private
37
+ * @type {string}
38
+ */
39
+ this._description = '';
40
+
41
+ // store the URL
42
+ this.url = url;
43
+
44
+ // set the text
45
+ this.text = text || this.url;
46
+
47
+ // set the description
48
+ this.description = description || this._text;
49
+ }
50
+
51
+ /**
52
+ * @returns {string} a URL string
53
+ */
54
+ get url(){
55
+ return this._uri.toString();
56
+ }
57
+
58
+ /**
59
+ * Get or set the URL.
60
+ *
61
+ * @param {string} url - A new URL as a string.
62
+ */
63
+ set url(url){
64
+ this._uri = URI(String(url)).normalize();
65
+ }
66
+
67
+ /**
68
+ * Get the URL as a URI.js object.
69
+ *
70
+ * @returns {Object}
71
+ */
72
+ get uri(){
73
+ return this._uri.clone();
74
+ }
75
+
76
+ /**
77
+ * @returns {string}
78
+ */
79
+ get text(){
80
+ return this._text;
81
+ }
82
+
83
+ /**
84
+ * @param {string} [text] - New link text. The value will be coerced to a string and trimmed.
85
+ */
86
+ set text(text){
87
+ this._text = String(text).trim();
88
+ }
89
+
90
+ /**
91
+ * @returns {string}
92
+ */
93
+ get description(){
94
+ return this._description;
95
+ }
96
+
97
+ /**
98
+ * @param {string} description
99
+ */
100
+ set description(description){
101
+ this._description = String(description);
102
+ }
103
+
104
+ /**
105
+ * Get the link data as a plain object of the form:
106
+ * ```
107
+ * {
108
+ * url: 'http://www.bartificer.net/',
109
+ * text: 'the link text',
110
+ * description: 'the link description',
111
+ * uri: {
112
+ * hostname: 'www.bartificer.net',
113
+ * path: '/',
114
+ * hasPath: false
115
+ * }
116
+ * }
117
+ * ```
118
+ *
119
+ * Note that the `uri` could contain more fields - it's initialised with
120
+ * output from the `URI.parse()` function from the `URI` module.
121
+ *
122
+ * @returns {plainObject}
123
+ * @see {@link https://medialize.github.io/URI.js/docs.html#static-parse}
124
+ */
125
+ asPlainObject(){
126
+ let ans = {
127
+ url: this.url,
128
+ text: this.text,
129
+ description: this.description,
130
+ uri: URI.parse(this._uri.toString())
131
+ };
132
+ ans.uri.hasPath = ans.uri.path !== '/';
133
+ return ans;
134
+ }
135
+ };
@@ -0,0 +1,125 @@
1
+ export class LinkTemplate{
2
+ /**
3
+ * @param {string} templateString - A Moustache template string.
4
+ * @param {Array} [filters=[]] - An optional array of filter functions.
5
+ * Each element in the array should itself be an array where the first
6
+ * element is a string specifying which fields the filter should be applied
7
+ * to (one of `'all'`, `'url'`, `'text'`, or `'description'`), and the
8
+ * second the filter function itself which should be a function that takes
9
+ * a single string as an argument and returns a filtered version of that
10
+ * string.
11
+ */
12
+ constructor(templateString, filters){
13
+ // TO DO - add validation
14
+
15
+ /**
16
+ * The Moustache template string.
17
+ *
18
+ * @private
19
+ * @type {templateString}
20
+ */
21
+ this._templateString = '';
22
+ this.templateString = templateString;
23
+
24
+ /**
25
+ * The filter functions to be applied to the various fields as a plain
26
+ * object of arrays of {@filterFunction} callbacks indexed by:
27
+ * * `all` - filters to be applied to all fields.
28
+ * * `url` - filters to be applied to just the URL.
29
+ * * `text` - filters to be applied just the link text.
30
+ * * `description` - filters to be applied just the link description.
31
+ *
32
+ * @private
33
+ * @type {Object.<string, filterFunction>}
34
+ */
35
+ this._filters = {
36
+ all: [],
37
+ url: [],
38
+ text: [],
39
+ description: []
40
+ };
41
+ if(Array.isArray(filters)){
42
+ for(let f of filters){
43
+ if(Array.isArray(f)){
44
+ this.addFilter(...f);
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Get the template string.
52
+ *
53
+ * @returns {string}
54
+ */
55
+ get templateString(){
56
+ return this._templateString;
57
+ }
58
+
59
+ /**
60
+ * Set the template string. Should be in Mustache format. All values passed
61
+ * will be coerced to strings.
62
+ *
63
+ * @param {string} templateString
64
+ */
65
+ set templateString(templateString){
66
+ this._templateString = String(templateString);
67
+ }
68
+
69
+ /**
70
+ * Add a filter to be applied to one or all fields.
71
+ *
72
+ * If an invalid args are passed, the function does not save the filter or
73
+ * throw an error, but it does log a warning.
74
+ *
75
+ * @param {string} fieldName - One of `'all'`, `'url'`, `'text'`, or
76
+ * `'description'`.
77
+ * @param {function} filterFn - the filter function.
78
+ * @returns {LinkTemplate} Returns a reference to self to facilitate function chaining.
79
+ */
80
+ addFilter(fieldName, filterFn){
81
+ // make sure that args are at least plausibly valid
82
+ if(typeof fieldName !== 'string' || typeof filterFn !== 'function'){
83
+ console.warn('silently ignoring request to add filter due to invalid args');
84
+ return this;
85
+ }
86
+
87
+ // make sure the field name is valid
88
+ if(!this._filters[fieldName]){
89
+ console.warn(`silently ignoring request to add filter for unknown field (${fieldName})`);
90
+ return this;
91
+ }
92
+
93
+ // add the filter
94
+ this._filters[fieldName].push(filterFn);
95
+
96
+ // return a reference to self
97
+ return this;
98
+ }
99
+
100
+ /**
101
+ * A function get the filter functions that should be applied to any given
102
+ * field.
103
+ *
104
+ * @param {string} fieldName - one of `'url'`, `'text'`, or
105
+ * `'description'`.
106
+ * @returns {function[]} returns an array of callbacks, which may be
107
+ * empty. An empty array is returned if an invalid field name is passed.
108
+ */
109
+ filtersFor(fieldName){
110
+ fieldName = String(fieldName);
111
+ let ans = [];
112
+
113
+ if(this._filters[fieldName]){
114
+ if(fieldName !== 'all'){
115
+ for(let f of this._filters.all){
116
+ ans.push(f);
117
+ }
118
+ }
119
+ for(let f of this._filters[fieldName]){
120
+ ans.push(f);
121
+ }
122
+ }
123
+ return ans;
124
+ }
125
+ };
@@ -0,0 +1,211 @@
1
+ import { PageData } from './PageData.class.mjs';
2
+ import { LinkData } from './LinkData.class.mjs';
3
+ import { LinkTemplate } from './LinkTemplate.class.mjs';
4
+ import * as utilities from "./utilities.mjs";
5
+
6
+ import fetch from 'node-fetch';
7
+ import * as cheerio from 'cheerio';
8
+ import Mustache from 'mustache';
9
+
10
+ export class Linkifier {
11
+ constructor(){
12
+ /**
13
+ * A mapping of domain names to data transformation functions.
14
+ *
15
+ * @private
16
+ * @type {Object.<FQDN, dataTransformer>}
17
+ */
18
+ this._pageDataToLinkDataTransmformers = {
19
+ '.' : function(pData){
20
+ let text = pData.title;
21
+ if(pData.h1s.length === 1){
22
+ text = pData.mainHeading;
23
+ }
24
+ return new LinkData(pData.url, text);
25
+ }
26
+ };
27
+
28
+ /**
29
+ * The registered link templates.
30
+ *
31
+ * @private
32
+ * @type {Object.<templateName, module:@bartificer/linkify.LinkTemplate>}
33
+ */
34
+ this._linkTemplates = {};
35
+
36
+ /**
37
+ * A collection of utility functions.
38
+ *
39
+ * @private
40
+ * @type {Object.<string, Function>}
41
+ */
42
+ this._utilities = utilities;
43
+
44
+ //
45
+ // === Create and register the default templates ===
46
+ //
47
+ // TO DO — migrate these to a separate file
48
+ this.registerTemplate(
49
+ 'html',
50
+ new LinkTemplate('<a href="{{{url}}}" title="{{description}}">{{text}}</a>')
51
+ );
52
+ this.registerTemplate(
53
+ 'htmlNewTab',
54
+ new LinkTemplate('<a href="{{{url}}}" title="{{description}}" target="_blank" rel="noopener">{{text}}</a>')
55
+ );
56
+ this.registerTemplate(
57
+ 'markdown',
58
+ new LinkTemplate('[{{{text}}}]({{{url}}})')
59
+ );
60
+ }
61
+
62
+ /**
63
+ * @type {Object.<string, Function>}
64
+ */
65
+ get utilities() {
66
+ return this._utilities;
67
+ }
68
+
69
+ /**
70
+ * @see Linfifier.utilities
71
+ */
72
+ get util(){
73
+ return this._utilities;
74
+ }
75
+
76
+ /**
77
+ * Register a data transformer function for a given domain.
78
+ *
79
+ * @param {domainName} domain - The domain for which this transformer should be
80
+ * used.
81
+ * @param {dataTransformer} transformerFn - The data transformer callback.
82
+ * @throws {ValidationError} A validation error is thrown if either parameter
83
+ * is missing or invalid.
84
+ */
85
+ registerTransformer(domain, transformerFn){
86
+ // TO DO - add validation
87
+
88
+ let fqdn = String(domain);
89
+ if(!fqdn.match(/[.]$/)){
90
+ fqdn += '.';
91
+ }
92
+ this._pageDataToLinkDataTransmformers[fqdn] = transformerFn;
93
+ }
94
+
95
+ /**
96
+ * Get the data transformer function for a given domain.
97
+ *
98
+ * Note that domains are searched from the subdomain up. For example, if passed
99
+ * the domain `www.bartificer.net` the function will first look for a
100
+ * transformer for the domain `www.bartificer.net`, if there's no transformer
101
+ * registered for that domain it will look for a transformer for the domain
102
+ * `bartificer.net`, if there's no transformer for that domain either it will
103
+ * return the default transformer.
104
+ *
105
+ * @param {domainName} domain - The domain to get the data transformer for.
106
+ * @returns {dataTransformer}
107
+ * @throws {ValidationError} A validation error is thrown unless a valid domain
108
+ * name is passed.
109
+ */
110
+ getTransformerForDomain(domain){
111
+ // TO DO - add validation
112
+
113
+ let fqdn = String(domain);
114
+ if(!fqdn.match(/[.]$/)){
115
+ fqdn += '.';
116
+ }
117
+
118
+ // return the most exact match
119
+ while(fqdn.match(/[.][^.]+[.]$/)){
120
+ if(this._pageDataToLinkDataTransmformers[fqdn]){
121
+ //console.log(`returning transformer for '${fqdn}'`);
122
+ return this._pageDataToLinkDataTransmformers[fqdn];
123
+ }
124
+ //console.log(`no transformer found for '${fqdn}'`);
125
+ fqdn = fqdn.replace(/^[^.]+[.]/, '');
126
+ }
127
+ //console.log('returning default transformer');
128
+ return this._pageDataToLinkDataTransmformers['.'];
129
+ }
130
+
131
+ /**
132
+ * Register a link template.
133
+ *
134
+ * @param {templateName} name
135
+ * @param {module:@bartificer/linkify.LinkTemplate} template
136
+ * @throws {ValidationError} A validation error is thrown unless both a valid
137
+ * name and template object are passed.
138
+ */
139
+ registerTemplate(name, template){
140
+ // TO DO - add validation
141
+
142
+ this._linkTemplates[name] = template;
143
+ }
144
+
145
+ /**
146
+ * Fetch the page data for a given URL.
147
+ *
148
+ * @async
149
+ * @param {URL} url
150
+ * @returns {PageData}
151
+ * @throws {ValidationError} A validation error is thrown unless a valid URL is
152
+ * passed.
153
+ */
154
+ async fetchPageData(url){
155
+ // TO DO - add validation
156
+
157
+ let ans = new PageData(url);
158
+
159
+ // then try load the contents form the web
160
+ let webDownloadResponseBody = '';
161
+ try {
162
+ let webDownloadResponse = await fetch(url);
163
+ if(!webDownloadResponse.ok){
164
+ throw new Error(`HTTP ${webDownloadResponse.status}: ${webDownloadResponse.statusText}`);
165
+ }
166
+ webDownloadResponseBody = await webDownloadResponse.text();
167
+ } catch (err) {
168
+ // fall back to extracting the title from the URL slug
169
+ console.warn(`Failed to fetch page data for '${url}': ${err.message}`);
170
+ console.warn('Falling back to reversing the URL slug for the title');
171
+ ans.title = this.utilities.extractSlug(url) || 'Untitled';
172
+ return ans;
173
+ }
174
+ let $ = cheerio.load(webDownloadResponseBody);
175
+ ans.title = $('title').text().trim();
176
+ $('h1').each(function(){
177
+ ans.h1($(this).text().trim());
178
+ });
179
+ $('h2').each(function(){
180
+ ans.h2($(this).text().trim());
181
+ });
182
+
183
+ // return the answer
184
+ return ans;
185
+ }
186
+
187
+ /**
188
+ * Generate a link given a URL.
189
+ *
190
+ * @async
191
+ * @param {URL} url
192
+ * @param {templateName} [templateName='html']
193
+ * @returns {string}
194
+ * @throws {ValidationError} A validation error is thrown unless a valid URL is
195
+ * passed.
196
+ */
197
+ async generateLink(url, templateName){
198
+ // TO DO - add validation
199
+
200
+ let tplName = templateName && typeof templateName === 'string' ? templateName : 'html';
201
+
202
+ // get the page data
203
+ let pData = await this.fetchPageData(url);
204
+
205
+ // transform the page data to link data
206
+ let lData = this.getTransformerForDomain(pData.uri.hostname())(pData);
207
+
208
+ // render the link
209
+ return Mustache.render(this._linkTemplates[tplName].templateString, lData.asPlainObject());
210
+ }
211
+ };