@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/LICENSE +21 -0
- package/README.md +2 -0
- package/dist/index.js +2 -0
- package/package.json +42 -0
- package/src/LinkData.class.mjs +135 -0
- package/src/LinkTemplate.class.mjs +125 -0
- package/src/Linkifier.class.mjs +211 -0
- package/src/PageData.class.mjs +257 -0
- package/src/index.js +12 -0
- package/src/utilities.mjs +54 -0
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
|
+
};
|