Redo app from the ground up
Make it an ES module, TODO: Typescript Make the themes explicitly mastodon Signed-off-by: Sam Therapy <sam@samtherapy.net>
This commit is contained in:
parent
2f34e2a7d9
commit
73a0ccf871
22 changed files with 1849 additions and 1459 deletions
27
README.md
27
README.md
|
@ -10,11 +10,11 @@ The homepage has a tool for generating iframe code for you, with a sensible `san
|
|||
|
||||
## API
|
||||
|
||||
### V2
|
||||
### V1
|
||||
|
||||
#### GET `/api/v2/feed`
|
||||
#### GET `/api/v1/feed`
|
||||
|
||||
> example: `/api/feed?userurl=https%3A%2F%2Foctodon.social%2Fusers%2Ffenwick67&scale=90&theme=light`
|
||||
> example: `/api/feed?userurl=https%3A%2F%2Foctodon.social%2Fusers%2Ffenwick67&scale=90&theme=masto-light`
|
||||
|
||||
Returns a html page which displays a mastodon feed for a user URL. Note that URLs must be URI encoded (i.e. `encodeURIComponent('https://octodon.social/users/fenwick67')` ).
|
||||
|
||||
|
@ -24,32 +24,15 @@ Querystring options:
|
|||
| ------ | -------- | ----------- |
|
||||
| `userurl` | **yes** | Mastodon/ActivityPub account URL (usually `https://${instance}/users/${username}`) |
|
||||
| `feedurl` | no | a URL to a page of an ActivityPub post collection. Only used for pages beyond the first. |
|
||||
| `theme` | no | either `dark`, `light` or `auto`, to select the UI theme (default is `dark`). `auto` will appear light unless the user sets up dark mode on their device. |
|
||||
| `theme` | no | either `masto-dark`, `masto-light` or `masto-auto`, to select the UI theme (default is `masto-dark`). `auto` will appear masto-light unless the user sets up masto-dark mode on their device. |
|
||||
| `boosts` | no | whether to show boosts or not |
|
||||
| `replies` | no | whether to show replies or not |
|
||||
| `size` | no | the scale of the UI in percent. |
|
||||
|
||||
### V1 (deprecated, will now just redirect you to the v2 API)
|
||||
|
||||
#### GET `/api/feed`
|
||||
|
||||
> example: `/api/feed?url=https%3A%2F%2Foctodon.social%2Fusers%2Ffenwick67.atom&scale=90&theme=light`
|
||||
|
||||
Returns a html page which displays a mastodon feed for an atom feed URL. Note that URLs must be URI encoded (i.e. `encodeURIComponent('https://octodon.social/users/fenwick67.atom')` ).
|
||||
|
||||
Querystring options:
|
||||
|
||||
|
||||
| option | required | description |
|
||||
| ------ | -------- | ----------- |
|
||||
| `url` | **yes** | Mastodon Atom feed URL |
|
||||
| `theme` | no | either dark, light or auto, to select the UI theme (default is dark). `auto` will appear light unless the user sets up dark mode on their device. |
|
||||
| `size` | no | the scale of the UI in percent. |
|
||||
|
||||
## Server Installation
|
||||
|
||||
This is a straightforward node project with zero databases or anything, you should just be able to run `npm install` and then `npm start` to get up and running. Set your `PORT` environment variable to change the port it listens on.
|
||||
|
||||
## Improve me
|
||||
|
||||
Feel free to add a chaching layer, improve the styles and add more features.
|
||||
Feel free to add a caching layer, improve the styles and add more features.
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
// build the styles
|
||||
var fs = require('fs');
|
||||
var sass = require('node-sass');
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { renderSync } from 'node-sass';
|
||||
|
||||
var staticDir = './static/'
|
||||
var srcDir = './stylesrc/';
|
||||
var themes = ['light','dark','auto'];
|
||||
var themes = ['masto-light','masto-dark','masto-auto'];
|
||||
|
||||
|
||||
themes.forEach(function(s){
|
||||
var sassFile = srcDir+s+'.scss'
|
||||
var cssFile = staticDir+s+'.css'
|
||||
var result = sass.renderSync({
|
||||
data: fs.readFileSync(sassFile,'utf8'),
|
||||
var result = renderSync({
|
||||
data: readFileSync(sassFile,'utf8'),
|
||||
includePaths:[srcDir]
|
||||
});
|
||||
|
||||
fs.writeFileSync(cssFile,result.css,'utf8')
|
||||
writeFileSync(cssFile,result.css,'utf8')
|
||||
|
||||
});
|
||||
|
||||
|
|
23
index.js
23
index.js
|
@ -1,13 +1,10 @@
|
|||
var Express = require('express');
|
||||
// v1 api
|
||||
var convert = require('./lib/convert');
|
||||
import Express from 'express';
|
||||
// v2 api
|
||||
var convertv2 = require('./lib/convertv2');
|
||||
var serveStatic = require('serve-static');
|
||||
var request = require('request');
|
||||
var cors = require('cors');
|
||||
var errorPage = require('./lib/errorPage');
|
||||
var morgan = require('morgan');
|
||||
import convertv2 from './lib/convert.js';
|
||||
import serveStatic from 'serve-static';
|
||||
import cors from 'cors';
|
||||
import errorPage from './lib/errorPage.js';
|
||||
import morgan from 'morgan';
|
||||
|
||||
var app = Express();
|
||||
|
||||
|
@ -40,7 +37,7 @@ app.get('/api/feed',cors(),logger,function(req,res){
|
|||
|
||||
var userUrl = feedUrl.replace(/\.atom.*/i,'');
|
||||
|
||||
var redirectUrl = '/api/v2/feed?';
|
||||
var redirectUrl = '/api/v1/feed?';
|
||||
var qs = ['userurl='+encodeURIComponent(userUrl),"api=v1"];
|
||||
|
||||
(['size','theme','boosts','replies']).forEach(key=>{
|
||||
|
@ -53,9 +50,9 @@ app.get('/api/feed',cors(),logger,function(req,res){
|
|||
|
||||
});
|
||||
|
||||
app.options('/api/v2/feed',cors());
|
||||
// http://localhost:8000/api/v2/feed?userurl=https%3A%2F%2Foctodon.social%2Fusers%2Ffenwick67
|
||||
app.get('/api/v2/feed',cors(),logger,function(req,res){
|
||||
app.options('/api/v1/feed',cors());
|
||||
// http://localhost:8000/api/v1/feed?userurl=https%3A%2F%2Foctodon.social%2Fusers%2Ffenwick67
|
||||
app.get('/api/v1/feed',cors(),logger,function(req,res){
|
||||
|
||||
// get feed url
|
||||
var userUrl = req.query.userurl;
|
||||
|
|
564
lib/convert.js
564
lib/convert.js
|
@ -1,312 +1,252 @@
|
|||
var FeedParser = require('feedparser');
|
||||
var ejs = require('ejs');
|
||||
var fs = require('fs');
|
||||
var template = ejs.compile(fs.readFileSync('./lib/template.ejs','utf8'));
|
||||
var timeAgo = require('timeago.js');
|
||||
|
||||
function isArray(a){
|
||||
return Array.isArray(a);
|
||||
}
|
||||
|
||||
// accumulate a stream of XML into a html file
|
||||
|
||||
module.exports = function(stream,opts,callback){
|
||||
var callback = callback;
|
||||
var opts = opts;
|
||||
if (typeof opts == 'function'){
|
||||
callback = opts;
|
||||
opts = {};
|
||||
}
|
||||
|
||||
// convert s from atom feed to a full html page for rendering
|
||||
breakDown(stream,function(er,data){
|
||||
if (er) {
|
||||
return callback(er);
|
||||
}
|
||||
// try and build up
|
||||
try{
|
||||
var result = buildUp(data,opts)
|
||||
}catch(e){
|
||||
return callback(e);
|
||||
}
|
||||
return callback(null,result);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// break the xml into json
|
||||
function breakDown(stream,callback){
|
||||
|
||||
var spent = false;
|
||||
function cbOnce(er,data){
|
||||
if (!spent){
|
||||
callback(er,data);
|
||||
spent = true;
|
||||
}
|
||||
}
|
||||
|
||||
stream.on('error',cbOnce);
|
||||
var feedparser = new FeedParser();
|
||||
feedparser.on('error', cbOnce);
|
||||
stream.pipe(feedparser)
|
||||
|
||||
|
||||
feedparser.items = [];
|
||||
feedparser.on('readable', function () {
|
||||
// This is where the action is!
|
||||
var stream = this; // `this` is `feedparser`, which is a stream
|
||||
var items = [];
|
||||
var item;
|
||||
|
||||
while (item = stream.read()) {
|
||||
feedparser.items.push(item);
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
feedparser.on('end',function(er){cbOnce(null,feedparser)});
|
||||
|
||||
}
|
||||
|
||||
// hydrate the json to html
|
||||
function buildUp(jsonObj,opts){
|
||||
|
||||
// assign opts to the obj
|
||||
jsonObj.opts = opts||{};
|
||||
|
||||
// iterate through the items
|
||||
jsonObj.items = jsonObj.items.filter(function(item){
|
||||
|
||||
// get date
|
||||
item.stringDate = getTimeDisplay(item.date);
|
||||
|
||||
item.content = getH(item,'atom:content');
|
||||
if (!item.content ){// item was deleted
|
||||
return false;
|
||||
}
|
||||
|
||||
// make anchor tags have the "_top" target
|
||||
item.content = item.content.replace(/\<\s*a\s*/ig,'<a target="_top"');
|
||||
|
||||
// get enclosures
|
||||
item.enclosures = [];
|
||||
|
||||
function findEnclosure(link){
|
||||
if (!link['@']){return;} // avoid keyerror
|
||||
var rel = link['@'].rel;
|
||||
var href = link['@'].href;
|
||||
if (rel == 'enclosure'){
|
||||
item.enclosures.push({
|
||||
url:href,
|
||||
type:link['@'].type||''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// find them in the item's links
|
||||
if (item['atom:link']){
|
||||
var links = item['atom:link'];
|
||||
if (!isArray(links) && typeof links == 'object'){
|
||||
links = [links];
|
||||
}else if (!isArray(links)){
|
||||
links = [];
|
||||
}
|
||||
links.forEach(findEnclosure);
|
||||
}
|
||||
|
||||
// find them in activity:object
|
||||
if (item["activity:object"] && item["activity:object"].link){
|
||||
var enclosureLinks = item["activity:object"].link;
|
||||
if (!isArray(enclosureLinks) && typeof enclosureLinks == 'object'){
|
||||
enclosureLinks = [enclosureLinks];
|
||||
}else if (!isArray(enclosureLinks)){
|
||||
enclosureLinks = [];// not an object or array.
|
||||
}
|
||||
enclosureLinks.forEach(findEnclosure);
|
||||
}
|
||||
|
||||
|
||||
// get author info
|
||||
|
||||
item.author = {};
|
||||
var _author = item.meta['atom:author'];
|
||||
if ( item['activity:object'] && item['activity:object'].author){
|
||||
_author = item['activity:object'].author;
|
||||
}
|
||||
|
||||
item.author.name = getH(_author,'name');
|
||||
item.author.uri = getH(_author,'uri');
|
||||
item.author.fullName = getH(_author,'email');
|
||||
item.author.displayName = getH(_author,'poco:displayname')||getH(_author,'poco:preferredUsername')||item.author.name;
|
||||
|
||||
var authorLinks = _author.link || [];
|
||||
|
||||
if (!isArray(authorLinks) && typeof authorLinks == 'object'){
|
||||
authorLinks = [authorLinks];
|
||||
}else if ( !isArray(authorLinks) ){
|
||||
authorLinks = [];// not an object or string.
|
||||
}
|
||||
|
||||
authorLinks.forEach(function(link){// todo: guard against authorLinks not being an array
|
||||
if (!link['@']){return;} // avoid keyerror
|
||||
var rel = link['@'].rel;
|
||||
var href = link['@'].href;
|
||||
if (rel == 'avatar'){
|
||||
item.author.avatar = href;
|
||||
}else if(rel == 'alternate'){
|
||||
item.author.alternate = href;
|
||||
}
|
||||
});
|
||||
|
||||
// now detect if item is a reply or boost
|
||||
item.isBoost = false;// <activity:verb>http://activitystrea.ms/schema/1.0/share</activity:verb>
|
||||
item.isReply = false;
|
||||
|
||||
var v = getH(item,'activity:verb')
|
||||
if (v.indexOf('share') > -1){
|
||||
item.isBoost = true;
|
||||
}
|
||||
|
||||
if (item['thr:in-reply-to']){
|
||||
item.isReply = true;
|
||||
}
|
||||
|
||||
if(item.summary){
|
||||
item.hasCw = true;
|
||||
item.cw = item.summary;
|
||||
}
|
||||
|
||||
if(item['activity:object'] && item['activity:object'].summary && item['activity:object'].summary['#']){
|
||||
item.hasCw = true;
|
||||
item.cw = item['activity:object'].summary['#'];
|
||||
}
|
||||
|
||||
|
||||
// get a pagination ID for an entry
|
||||
item.paginationId = false;
|
||||
if (item['atom:link']){
|
||||
var links = item['atom:link'];
|
||||
if (!isArray(links) && typeof links == 'object'){
|
||||
links = [links];
|
||||
}else if (!isArray(links)){
|
||||
links = [];
|
||||
}
|
||||
links.forEach((link)=>{
|
||||
if (!link['@']){return}
|
||||
if (link['@']['rel']=='self'){
|
||||
// parse out the pagination id
|
||||
// href looks like this in mastodon: https://octodon.social/users/fenwick67/updates/732275.atom
|
||||
// href looks like this in pleroma (and we should ignore): https://social.fenwick.pizza/objects/1e2fa906-378c-43f8-98fa-271aae455758
|
||||
var href = link['@']['href'];
|
||||
if (!href){return}
|
||||
item.atomHref = href;
|
||||
var match = href.match(/\/\d+.atom/);
|
||||
if(!match){return}
|
||||
var id = match[0].replace(/\D/g,'');
|
||||
if (id){
|
||||
item.paginationId = id;
|
||||
}else{
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
});
|
||||
|
||||
if (jsonObj.meta && jsonObj.meta['atom:author'] && jsonObj.meta['atom:author'].link && Array.isArray(jsonObj.meta['atom:author'].link) ){
|
||||
jsonObj.meta['atom:author'].link.forEach(link=>{
|
||||
var l = link['@'];
|
||||
if (l.rel=="header"){
|
||||
jsonObj.meta.headerImage = l.href;
|
||||
}
|
||||
else if(l.rel=="avatar"){
|
||||
jsonObj.meta.avatar = l.href;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// get next page
|
||||
jsonObj.feedUrl = opts.feedUrl;
|
||||
jsonObj.isIndex = (opts.feedUrl.indexOf('?') == -1);
|
||||
|
||||
// prefer link(rel=next)
|
||||
var nextPageFeedUrl = '';
|
||||
var links = jsonObj.meta['atom:link'];
|
||||
if (links){
|
||||
if (!isArray(links) && typeof links == 'object'){
|
||||
links = [links];
|
||||
}else if (!isArray(links)){
|
||||
links = [];
|
||||
}
|
||||
links.forEach(function(link){
|
||||
if (link['@'] && link['@']['rel'] == 'next' && link['@']['href']){
|
||||
nextPageFeedUrl = link['@']['href'];
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!nextPageFeedUrl){
|
||||
// alternative: try to get the oldest entry id on this page
|
||||
var lowestId = Infinity;
|
||||
jsonObj.items.forEach(function(item){
|
||||
var id = Number(item.paginationId);
|
||||
if ( id < lowestId ){
|
||||
lowestId = id;
|
||||
}
|
||||
});
|
||||
|
||||
if (lowestId < Infinity && opts.feedUrl){
|
||||
nextPageFeedUrl = opts.feedUrl.replace(/\?.+/g,'') + '?max_id='+lowestId;
|
||||
}
|
||||
}
|
||||
|
||||
if(nextPageFeedUrl){
|
||||
jsonObj.nextPageLink = opts.mastofeedUrl.replace(encodeURIComponent(opts.feedUrl),encodeURIComponent(nextPageFeedUrl));
|
||||
console.log(jsonObj.nextPageLink);
|
||||
}
|
||||
return template(jsonObj);
|
||||
}
|
||||
|
||||
// utilities below
|
||||
|
||||
|
||||
// get obj[key]['#'] or ''
|
||||
function getH(obj,key){
|
||||
if (!obj[key]){return ''}
|
||||
return obj[key]['#']||'';
|
||||
}
|
||||
|
||||
function getTimeDisplay(d){
|
||||
var d = d;
|
||||
if (typeof d !== 'object'){
|
||||
d = new Date(d);
|
||||
}
|
||||
// convert to number
|
||||
dt = d.getTime();
|
||||
var now = Date.now();
|
||||
|
||||
var delta = now - dt;
|
||||
|
||||
// over 6 days ago
|
||||
if (delta > 1000*60*60*24*6){
|
||||
return isoDateToEnglish(d.toISOString());
|
||||
}else{
|
||||
return timeAgo().format(dt);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function isoDateToEnglish(d){
|
||||
|
||||
var dt = d.split(/[t\-]/ig);
|
||||
var months = [ "January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December" ];
|
||||
|
||||
return months[Number(dt[1])-1] +' '+dt[2]+ ', '+dt[0];
|
||||
}
|
||||
import { compile } from 'ejs';
|
||||
import { readFileSync } from 'fs';
|
||||
var template = compile(readFileSync('./lib/template.ejs', 'utf8'));
|
||||
import { format } from 'timeago.js';
|
||||
|
||||
import got from 'got';
|
||||
const map = new Map();
|
||||
|
||||
const hour = 3600000;
|
||||
|
||||
// get JSON for an AP URL, by either fetching it or grabbing it from a cache.
|
||||
|
||||
// Honestly request-promise-cache should be good enough. Redis would be a nice upgrade but for
|
||||
// a single process install it will be fine.
|
||||
|
||||
// note: rejects on HTTP 4xx or 5xx
|
||||
async function apGet(url,ttl) {
|
||||
|
||||
return new Promise(function(resolve,reject){
|
||||
|
||||
// fail early
|
||||
if (!url){
|
||||
reject(new Error('URL is invalid'));
|
||||
}
|
||||
|
||||
got( {
|
||||
url:url,
|
||||
cache:map,
|
||||
headers: {
|
||||
"accept": "application/activity+json"
|
||||
}
|
||||
})
|
||||
.then(response=>JSON.parse(response.body))
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// like Promise.all except returns null on error instead of failing all the promises
|
||||
async function promiseSome(proms){
|
||||
|
||||
function noRejectWrap(prom){
|
||||
return new Promise(function(resolve,reject){
|
||||
|
||||
prom // it's already been called
|
||||
.then(resolve)
|
||||
.catch(e=>{
|
||||
// console.warn(e);// for debugging
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
return await Promise.all(proms.map(noRejectWrap))
|
||||
|
||||
}
|
||||
|
||||
export default async function (opts) {
|
||||
var opts = opts;
|
||||
|
||||
var feedUrl = opts.feedUrl;
|
||||
var userUrl = opts.userUrl;
|
||||
var isIndex = false;
|
||||
|
||||
if (!userUrl) {
|
||||
throw new Error('need user URL');
|
||||
}
|
||||
|
||||
var user, feed;
|
||||
|
||||
// get user and feed in parallel if I have both URLs.
|
||||
// can cache feed aggressively since it is a specific start and end.
|
||||
if (userUrl && feedUrl){
|
||||
[user, feed] = await Promise.all([ apGet(userUrl), apGet(feedUrl) ]);
|
||||
}else{
|
||||
// get user, then outbox, then feed
|
||||
|
||||
user = await apGet(userUrl,24 * hour);
|
||||
isIndex = true;
|
||||
var outbox = await apGet(user.outbox, 1 * hour);
|
||||
|
||||
// outbox.first can be a string for a URL, or an object with stuffs in it
|
||||
if (typeof outbox.first == 'object'){
|
||||
feed = outbox.first;
|
||||
} else {
|
||||
feed = await apGet(outbox.first, hour/6);// 10 mins. Because the base feed URL can get new toots quickly.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var templateData = {
|
||||
opts: opts,// from the request
|
||||
meta: metaForUser(user),
|
||||
items: await itemsForFeed(opts,user,feed),
|
||||
nextPageLink: getNextPage(opts,user,feed),
|
||||
isIndex: isIndex
|
||||
};
|
||||
|
||||
return template(templateData);
|
||||
|
||||
}
|
||||
|
||||
function metaForUser(user) {
|
||||
return {
|
||||
avatar: user.icon && user.icon.url?user.icon.url:null,
|
||||
headerImage:user.image && user.image.url?user.image.url:null,
|
||||
title: user.name||user.preferredUsername||null,
|
||||
description: user.summary||null,
|
||||
link:user.url||"#"
|
||||
}
|
||||
}
|
||||
|
||||
async function itemsForFeed(opts,user,feed) {
|
||||
|
||||
var items = feed.orderedItems;
|
||||
|
||||
if (opts.boosts){
|
||||
// yes, I have to fetch all the fucking boosts for this whole feed apparently >:/
|
||||
var boostData = [];
|
||||
var boostUrls = feed.orderedItems.filter(i=>i.type=="Announce").map(i=>i.object);
|
||||
// console.log(boostUrls);
|
||||
boostData = await promiseSome(boostUrls.map(apGet));
|
||||
|
||||
// now get user data for each of those
|
||||
let userData = await promiseSome(boostData.map(d=>d?d.attributedTo||'':null).map(apGet));
|
||||
|
||||
// put a ._userdata key on the item object if this is a boost
|
||||
for (var i = 0; i < boostData.length; i ++){
|
||||
if (userData[i] && boostData[i]){
|
||||
boostData[i]._userdata = userData[i];
|
||||
}
|
||||
}
|
||||
|
||||
// some URLs may have failed but IDGAF
|
||||
|
||||
// console.log(boostData[0]);
|
||||
|
||||
boostData.forEach((boostToot)=>{
|
||||
|
||||
if (!boostToot){// failed request
|
||||
return;
|
||||
}
|
||||
|
||||
// inject in-place into items
|
||||
|
||||
var index = -1;
|
||||
for (var i = 0; i < items.length; i ++){
|
||||
if (items[i].object == boostToot.id){
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (index == -1){
|
||||
console.warn("warning: couldn't match boost to item: ",boostToot)
|
||||
return;
|
||||
}
|
||||
|
||||
boostToot.object = boostToot;// this lets the later stage parser access object without errors :)
|
||||
items[i] = boostToot;
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return items.filter((item)=>{
|
||||
return typeof item.object == 'object';// handle weird cases
|
||||
}).map((item)=>{
|
||||
|
||||
var enclosures = (item.object.attachment||[]).filter((a)=>{
|
||||
return a.type == "Document";
|
||||
}).map((a)=>{
|
||||
return {
|
||||
name:a.name,
|
||||
type:a.mediaType,
|
||||
url:a.url
|
||||
}
|
||||
});
|
||||
|
||||
var op = item._userdata?item._userdata:user;
|
||||
|
||||
return {
|
||||
isBoost:!!item._userdata,
|
||||
title:item._userdata?user.preferredUsername+' shared a status by '+op.preferredUsername:'',
|
||||
isReply:!!(item.object && item.object.inReplyTo),
|
||||
hasCw:item.object.sensitive||false,
|
||||
cw:item.object.summary,
|
||||
content: item.object&&item.object.content?item.object.content:'',//TODO sanitize then render without entity escapes
|
||||
atomHref:item.published?item.published.replace(/\W+/g,''):Math.random().toString().replace('.',''),// used for IDs
|
||||
enclosures:enclosures,
|
||||
stringDate:item.published?getTimeDisplay(Date.parse(item.published)):'',
|
||||
permalink:item.object.id?item.object.id:'#',
|
||||
author:{
|
||||
uri:op.url,// link to author page
|
||||
avatar:op.icon&&op.icon.url?op.icon.url:'',
|
||||
displayName:op.name || op.preferredUsername,
|
||||
fullName:op.preferredUsername+'@'+(new URL(op.url).hostname),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getNextPage(opts,user,feed){
|
||||
//based on feed.next
|
||||
if (!feed.next){return null}
|
||||
// take feed.next, uriencode it, then take user url, then take options.mastofeedUrl
|
||||
var base = opts.mastofeedUrl.slice(0,opts.mastofeedUrl.indexOf('?'));
|
||||
|
||||
var ret = '/api/v1/feed?userurl=' + encodeURIComponent(opts.userUrl) + '&feedurl=' +encodeURIComponent(feed.next);
|
||||
|
||||
// add other params to the end
|
||||
(['theme','header','size','boosts','replies']).forEach((k)=>{
|
||||
if (typeof opts[k] != 'undefined'){
|
||||
ret+=`&${k}=${ opts[k].toString() }`;
|
||||
}
|
||||
})
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
// utilities below
|
||||
|
||||
function getTimeDisplay(d) {
|
||||
var d = d;
|
||||
if (typeof d !== 'object') {
|
||||
d = new Date(d);
|
||||
}
|
||||
// convert to number
|
||||
let dt = d.getTime();
|
||||
var now = Date.now();
|
||||
|
||||
var delta = now - dt;
|
||||
|
||||
// over 6 days ago
|
||||
if (delta > 1000 * 60 * 60 * 24 * 6) {
|
||||
return isoDateToEnglish(d.toISOString());
|
||||
} else {
|
||||
return format(dt);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function isoDateToEnglish(d) {
|
||||
|
||||
var dt = d.split(/[t\-]/ig);
|
||||
var months = ["January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"];
|
||||
|
||||
return months[Number(dt[1]) - 1] + ' ' + dt[2] + ', ' + dt[0];
|
||||
}
|
||||
|
|
252
lib/convertv2.js
252
lib/convertv2.js
|
@ -1,252 +0,0 @@
|
|||
var ejs = require('ejs');
|
||||
var fs = require('fs');
|
||||
var template = ejs.compile(fs.readFileSync('./lib/template.ejs', 'utf8'));
|
||||
var timeAgo = require('timeago.js');
|
||||
|
||||
var request = require('request-promise-cache')
|
||||
|
||||
const hour = 3600000;
|
||||
|
||||
// get JSON for an AP URL, by either fetching it or grabbing it from a cache.
|
||||
|
||||
// Honestly request-promise-cache should be good enough. Redis would be a nice upgrade but for
|
||||
// a single process install it will be fine.
|
||||
|
||||
// note: rejects on HTTP 4xx or 5xx
|
||||
async function apGet(url,ttl) {
|
||||
|
||||
return new Promise(function(resolve,reject){
|
||||
|
||||
// fail early
|
||||
if (!url){
|
||||
reject(new Error('URL is invalid'));
|
||||
}
|
||||
|
||||
request( {
|
||||
uri:url,
|
||||
cacheKey:url,
|
||||
cacheTTL:ttl || 24 * hour,
|
||||
headers: {
|
||||
"accept": "application/activity+json"
|
||||
}
|
||||
})
|
||||
.then(body=>JSON.parse(body))
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// like Promise.all except returns null on error instead of failing all the promises
|
||||
async function promiseSome(proms){
|
||||
|
||||
function noRejectWrap(prom){
|
||||
return new Promise(function(resolve,reject){
|
||||
|
||||
prom // it's already been called
|
||||
.then(resolve)
|
||||
.catch(e=>{
|
||||
// console.warn(e);// for debugging
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
return await Promise.all(proms.map(noRejectWrap))
|
||||
|
||||
}
|
||||
|
||||
module.exports = async function (opts) {
|
||||
var opts = opts;
|
||||
|
||||
var feedUrl = opts.feedUrl;
|
||||
var userUrl = opts.userUrl;
|
||||
var isIndex = false;
|
||||
|
||||
if (!userUrl) {
|
||||
throw new Error('need user URL');
|
||||
}
|
||||
|
||||
var user, feed;
|
||||
|
||||
// get user and feed in parallel if I have both URLs.
|
||||
// can cache feed aggressively since it is a specific start and end.
|
||||
if (userUrl && feedUrl){
|
||||
[user, feed] = await Promise.all([ apGet(userUrl), apGet(feedUrl) ]);
|
||||
}else{
|
||||
// get user, then outbox, then feed
|
||||
|
||||
user = await apGet(userUrl,24 * hour);
|
||||
isIndex = true;
|
||||
var outbox = await apGet(user.outbox, 1 * hour);
|
||||
|
||||
// outbox.first can be a string for a URL, or an object with stuffs in it
|
||||
if (typeof outbox.first == 'object'){
|
||||
feed = outbox.first;
|
||||
} else {
|
||||
feed = await apGet(outbox.first, hour/6);// 10 mins. Because the base feed URL can get new toots quickly.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var templateData = {
|
||||
opts: opts,// from the request
|
||||
meta: metaForUser(user),
|
||||
items: await itemsForFeed(opts,user,feed),
|
||||
nextPageLink: getNextPage(opts,user,feed),
|
||||
isIndex: isIndex
|
||||
};
|
||||
|
||||
return template(templateData);
|
||||
|
||||
}
|
||||
|
||||
function metaForUser(user) {
|
||||
return {
|
||||
avatar: user.icon && user.icon.url?user.icon.url:null,
|
||||
headerImage:user.image && user.image.url?user.image.url:null,
|
||||
title: user.name||user.preferredUsername||null,
|
||||
description: user.summary||null,
|
||||
link:user.url||"#"
|
||||
}
|
||||
}
|
||||
|
||||
async function itemsForFeed(opts,user,feed) {
|
||||
|
||||
var items = feed.orderedItems;
|
||||
|
||||
if (opts.boosts){
|
||||
// yes, I have to fetch all the fucking boosts for this whole feed apparently >:/
|
||||
var boostData = [];
|
||||
var boostUrls = feed.orderedItems.filter(i=>i.type=="Announce").map(i=>i.object);
|
||||
// console.log(boostUrls);
|
||||
boostData = await promiseSome(boostUrls.map(apGet));
|
||||
|
||||
// now get user data for each of those
|
||||
let userData = await promiseSome(boostData.map(d=>d?d.attributedTo||'':null).map(apGet));
|
||||
|
||||
// put a ._userdata key on the item object if this is a boost
|
||||
for (var i = 0; i < boostData.length; i ++){
|
||||
if (userData[i] && boostData[i]){
|
||||
boostData[i]._userdata = userData[i];
|
||||
}
|
||||
}
|
||||
|
||||
// some URLs may have failed but IDGAF
|
||||
|
||||
// console.log(boostData[0]);
|
||||
|
||||
boostData.forEach((boostToot)=>{
|
||||
|
||||
if (!boostToot){// failed request
|
||||
return;
|
||||
}
|
||||
|
||||
// inject in-place into items
|
||||
|
||||
var index = -1;
|
||||
for (var i = 0; i < items.length; i ++){
|
||||
if (items[i].object == boostToot.id){
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (index == -1){
|
||||
console.warn("warning: couldn't match boost to item: ",boostToot)
|
||||
return;
|
||||
}
|
||||
|
||||
boostToot.object = boostToot;// this lets the later stage parser access object without errors :)
|
||||
items[i] = boostToot;
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return items.filter((item)=>{
|
||||
return typeof item.object == 'object';// handle weird cases
|
||||
}).map((item)=>{
|
||||
|
||||
var enclosures = (item.object.attachment||[]).filter((a)=>{
|
||||
return a.type == "Document";
|
||||
}).map((a)=>{
|
||||
return {
|
||||
name:a.name,
|
||||
type:a.mediaType,
|
||||
url:a.url
|
||||
}
|
||||
});
|
||||
|
||||
var op = item._userdata?item._userdata:user;
|
||||
|
||||
return {
|
||||
isBoost:!!item._userdata,
|
||||
title:item._userdata?user.preferredUsername+' shared a status by '+op.preferredUsername:'',
|
||||
isReply:!!(item.object && item.object.inReplyTo),
|
||||
hasCw:item.object.sensitive||false,
|
||||
cw:item.object.summary,
|
||||
content: item.object&&item.object.content?item.object.content:'',//TODO sanitize then render without entity escapes
|
||||
atomHref:item.published?item.published.replace(/\W+/g,''):Math.random().toString().replace('.',''),// used for IDs
|
||||
enclosures:enclosures,
|
||||
stringDate:item.published?getTimeDisplay(Date.parse(item.published)):'',
|
||||
permalink:item.object.id?item.object.id:'#',
|
||||
author:{
|
||||
uri:op.url,// link to author page
|
||||
avatar:op.icon&&op.icon.url?op.icon.url:'',
|
||||
displayName:op.name || op.preferredUsername,
|
||||
fullName:op.preferredUsername+'@'+(new URL(op.url).hostname),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getNextPage(opts,user,feed){
|
||||
//based on feed.next
|
||||
if (!feed.next){return null}
|
||||
// take feed.next, uriencode it, then take user url, then take options.mastofeedUrl
|
||||
var base = opts.mastofeedUrl.slice(0,opts.mastofeedUrl.indexOf('?'));
|
||||
|
||||
var ret = '/api/v2/feed?userurl=' + encodeURIComponent(opts.userUrl) + '&feedurl=' +encodeURIComponent(feed.next);
|
||||
|
||||
// add other params to the end
|
||||
(['theme','header','size','boosts','replies']).forEach((k)=>{
|
||||
if (typeof opts[k] != 'undefined'){
|
||||
ret+=`&${k}=${ opts[k].toString() }`;
|
||||
}
|
||||
})
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
// utilities below
|
||||
|
||||
function getTimeDisplay(d) {
|
||||
var d = d;
|
||||
if (typeof d !== 'object') {
|
||||
d = new Date(d);
|
||||
}
|
||||
// convert to number
|
||||
dt = d.getTime();
|
||||
var now = Date.now();
|
||||
|
||||
var delta = now - dt;
|
||||
|
||||
// over 6 days ago
|
||||
if (delta > 1000 * 60 * 60 * 24 * 6) {
|
||||
return isoDateToEnglish(d.toISOString());
|
||||
} else {
|
||||
return timeAgo().format(dt);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function isoDateToEnglish(d) {
|
||||
|
||||
var dt = d.split(/[t\-]/ig);
|
||||
var months = ["January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"];
|
||||
|
||||
return months[Number(dt[1]) - 1] + ' ' + dt[2] + ', ' + dt[0];
|
||||
}
|
|
@ -1,14 +1,14 @@
|
|||
var ejs = require('ejs');
|
||||
var fs = require('fs');
|
||||
var template = ejs.compile(fs.readFileSync('./lib/template.ejs', 'utf8'));
|
||||
import { compile } from 'ejs';
|
||||
import { readFileSync } from 'fs';
|
||||
var template = compile(readFileSync('./lib/template.ejs', 'utf8'));
|
||||
|
||||
module.exports = function(code,message,displayOptions){
|
||||
export default function(code,message,displayOptions){
|
||||
|
||||
var msg;
|
||||
var displayOptions = displayOptions || {};
|
||||
|
||||
if (code == 500 && !message){
|
||||
msg = '<p>Sorry, we are having trouble fetching posts for this user. Please try again later.</p><br><p>If the issue persists, <a href="https://github.com/fenwick67/mastofeed/issues">please open an issue on GitHub</a>, or message fenwick67@octodon.social</p>'
|
||||
msg = '<p>Sorry, we are having trouble fetching posts for this user. Please try again later.</p><br><p>If the issue persists, <a href="https://git.froth.zone/Sam/fedifeed/issues">please open an issue on Gitea</a>, or message sam@froth.zone</p>'
|
||||
}else{
|
||||
msg = message||'';
|
||||
}
|
||||
|
|
|
@ -4,12 +4,12 @@
|
|||
<style type="text/css"></style>
|
||||
<base target="_top" /><!-- this element is amazing-->
|
||||
|
||||
<% if (opts.theme && opts.theme.toLowerCase() == 'light'){ %>
|
||||
<link rel="stylesheet" href="/light.css"></link>
|
||||
<% if (opts.theme && opts.theme.toLowerCase() == 'masto-light'){ %>
|
||||
<link rel="stylesheet" href="/masto-light.css"></link>
|
||||
<% } else if (opts.theme && opts.theme.toLowerCase() == 'auto'){ %>
|
||||
<link rel="stylesheet" href="/auto.css"></link>
|
||||
<link rel="stylesheet" href="/masto-auto.css"></link>
|
||||
<% } else { %>
|
||||
<link rel="stylesheet" href="/dark.css"></link>
|
||||
<link rel="stylesheet" href="/masto-dark.css"></link>
|
||||
<% } %>
|
||||
|
||||
<% if (opts.size){ %>
|
||||
|
|
20
package.json
20
package.json
|
@ -1,22 +1,24 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"ejs": "^2.5.8",
|
||||
"express": "^4.16.4",
|
||||
"feedparser": "^2.2.9",
|
||||
"morgan": "^1.9.1",
|
||||
"request": "^2.88.0",
|
||||
"ejs": "^3.1.6",
|
||||
"express": "^4.17.2",
|
||||
"feedparser": "^2.2.10",
|
||||
"morgan": "^1.10.0",
|
||||
"got": "^12.0.1",
|
||||
"request": "^2.88.2",
|
||||
"request-promise-cache": "^2.0.1",
|
||||
"request-promise-native": "^1.0.7",
|
||||
"serve-static": "^1.13.2",
|
||||
"timeago.js": "^3.0.2"
|
||||
"serve-static": "^1.14.2",
|
||||
"timeago.js": "^4.0.2"
|
||||
},
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"build-styles": "node build-styles.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"node-sass": "^4.12.0"
|
||||
"node-sass": "^7.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,9 +39,9 @@
|
|||
<br>
|
||||
<label>Theme:
|
||||
<select id="theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="auto">Auto (based on css prefers-color-scheme)</option>
|
||||
<option value="masto-dark">masto-dark</option>
|
||||
<option value="masto-light">masto-light</option>
|
||||
<option value="masto-auto">masto-auto (based on css prefers-color-scheme)</option>
|
||||
</select>
|
||||
</label>
|
||||
<br>
|
||||
|
@ -61,7 +61,7 @@
|
|||
<h3>Live Preview:</h3>
|
||||
<span class="iframe-contain">
|
||||
<iframe id="frame" allowfullscreen sandbox="allow-top-navigation allow-scripts" width="400" height="800"
|
||||
src="/api/v2/feed?userurl=https%3A%2F%2Fmastodon.social%2Fusers%2Fgargron&replies=false&boosts=true"></iframe>
|
||||
src="/api/v1/feed?userurl=https%3A%2F%2Fmastodon.social%2Fusers%2Fgargron&replies=false&boosts=true"></iframe>
|
||||
</span>
|
||||
<br>
|
||||
</div>
|
||||
|
@ -79,7 +79,7 @@
|
|||
var portStr = (window.location.port && window.location.port != 80) ? (':' + window.location.port) : ''
|
||||
|
||||
var iframeUrl = window.location.protocol + '//' + window.location.hostname + portStr
|
||||
+ "/api/v2/feed?userurl=" + encodeURIComponent(inUrl) + "&theme=" + val('theme') + '&size=' + val('size')
|
||||
+ "/api/v1/feed?userurl=" + encodeURIComponent(inUrl) + "&theme=" + val('theme') + '&size=' + val('size')
|
||||
+ "&header=" + showHeader + '&replies=' + showReplies + '&boosts=' + showBoosts;
|
||||
|
||||
document.getElementById('result').value = '<iframe allowfullscreen sandbox="allow-top-navigation allow-scripts" width="' + val('width') + '" height="' + val('height') + '" src="' + iframeUrl + '"></iframe>';
|
||||
|
|
163
static/pleroma.css
Normal file
163
static/pleroma.css
Normal file
|
@ -0,0 +1,163 @@
|
|||
html,
|
||||
body {
|
||||
background-color: #282c37;
|
||||
font-family: 'Roboto', roboto, Arial, sans-serif;
|
||||
color: #ffffff;
|
||||
font-weight: lighter;
|
||||
overflow-x: hidden;
|
||||
font-size: 100%;
|
||||
word-break: break-word; }
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0; }
|
||||
|
||||
a,
|
||||
a * {
|
||||
color: #2b90d9; }
|
||||
|
||||
.meta {
|
||||
background-color: #39404d; }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
background-size: cover;
|
||||
min-height: 8rem;
|
||||
color: #ffffff; }
|
||||
.header .header-left, .header .header-right {
|
||||
margin: 0; }
|
||||
.header .header-left {
|
||||
min-width: 8rem;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
background: rgba(40, 44, 55, 0.3); }
|
||||
.header .header-left .avatar {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
position: relative;
|
||||
top: calc(50% - 3rem); }
|
||||
.header .header-right {
|
||||
flex-grow: 1;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.9rem;
|
||||
background: rgba(40, 44, 55, 0.85); }
|
||||
.header .header-title {
|
||||
font-size: 1.3rem; }
|
||||
|
||||
.item {
|
||||
padding: 1rem;
|
||||
border-top: solid 1px #626d80; }
|
||||
|
||||
.item-content,
|
||||
.cw,
|
||||
.title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: lighter; }
|
||||
|
||||
.item-content *, .cw {
|
||||
margin: 1rem 0;
|
||||
line-height: 1.4rem; }
|
||||
|
||||
.item-title,
|
||||
.date,
|
||||
.author-fullname {
|
||||
color: #9baec8;
|
||||
font-size: 0.9rem; }
|
||||
|
||||
.date {
|
||||
margin: 1rem 0 0 0;
|
||||
text-decoration: none;
|
||||
display: block; }
|
||||
|
||||
.date:hover {
|
||||
text-decoration: underline; }
|
||||
|
||||
.item-title {
|
||||
margin-bottom: 0.7rem; }
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
margin-bottom: 1rem; }
|
||||
|
||||
.author-info {
|
||||
margin: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around; }
|
||||
.author-info .author-displayname {
|
||||
font-size: 1.2rem;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
font-weight: bolder; }
|
||||
|
||||
.avatar {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: none;
|
||||
border-radius: 10%; }
|
||||
.avatar.circular {
|
||||
border-radius: 100%; }
|
||||
|
||||
.enclosures {
|
||||
padding: 0.5em 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
overflow: hidden; }
|
||||
|
||||
.enclosure {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
width: 50%;
|
||||
border: none;
|
||||
max-height: 12rem; }
|
||||
|
||||
a.enclosure {
|
||||
cursor: zoom-in; }
|
||||
|
||||
.enclosure > * {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; }
|
||||
|
||||
.meta .title {
|
||||
font-weight: bold; }
|
||||
|
||||
.hidden {
|
||||
display: none; }
|
||||
|
||||
.button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
margin: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
background: #2b90d9;
|
||||
color: #ffffff;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
user-select: none; }
|
||||
|
||||
label.button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0.4rem;
|
||||
background: #626d80;
|
||||
color: #282c37;
|
||||
font-size: 0.8rem; }
|
||||
|
||||
input[type=checkbox] {
|
||||
position: absolute;
|
||||
left: -9999px; }
|
||||
|
||||
input[type=checkbox]:checked ~ label::after {
|
||||
content: " Less"; }
|
||||
|
||||
input[type=checkbox]:not(:checked) ~ label::after {
|
||||
content: " More"; }
|
||||
|
||||
input[type=checkbox]:not(:checked) ~ div {
|
||||
display: none; }
|
|
@ -1,5 +0,0 @@
|
|||
@import 'light.scss';
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@import 'dark.scss';
|
||||
}
|
5
stylesrc/masto-auto.scss
Normal file
5
stylesrc/masto-auto.scss
Normal file
|
@ -0,0 +1,5 @@
|
|||
@import 'masto-light.scss';
|
||||
|
||||
@media (prefers-color-scheme: masto-dark) {
|
||||
@import 'masto-dark.scss';
|
||||
}
|
|
@ -7,4 +7,4 @@ $dim: $lighter;
|
|||
$dimmer: mix($darkest,$lighter,50%);
|
||||
$link: $vibrant;
|
||||
|
||||
@import 'base.scss';
|
||||
@import 'masto-base.scss';
|
|
@ -7,7 +7,7 @@ $dim: mix($lighter,$darkest,90%);
|
|||
$dimmer: mix($lighter,$darkest,80%);
|
||||
$link: $vibrant;
|
||||
|
||||
@import 'base.scss';
|
||||
@import 'masto-base.scss';
|
||||
|
||||
.item-content,
|
||||
.description,
|
|
@ -10,7 +10,7 @@
|
|||
</a>boosted
|
||||
</span>
|
||||
</div>
|
||||
<div class="detailed-status light">
|
||||
<div class="detailed-status masto-light">
|
||||
<a class="detailed-status__display-name p-author h-card" target="_blank" rel="noopener" href="https://social.tchncs.de/@muninnherself"><div>
|
||||
<div class="avatar">
|
||||
<img alt="" class="u-photo" src="https://assets.octodon.social/accounts/avatars/000/018/289/original/eb6b32e85f8606b4.jpg" width="48" height="48">
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<style type="text/css"></style>
|
||||
|
||||
|
||||
<link rel="stylesheet" href="/dark.css"></link>
|
||||
<link rel="stylesheet" href="/masto-dark.css"></link>
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
// do a test
|
||||
|
||||
|
||||
var fs = require('fs'),
|
||||
request = require('request'),
|
||||
convert = require('../lib/convert')
|
||||
import { createReadStream, writeFileSync } from 'fs';
|
||||
import convert from '../lib/convert.js';
|
||||
|
||||
|
||||
var r = fs.createReadStream('./test/sample.atom');
|
||||
var r = createReadStream('./test/sample.atom');
|
||||
|
||||
convert(r,function(er,data){
|
||||
if (er){return console.log('error: ',er)}
|
||||
console.log('ok');
|
||||
fs.writeFileSync('./test/result.html',data,'utf8');
|
||||
writeFileSync('./test/result.html',data,'utf8');
|
||||
})
|
||||
|
|
Reference in a new issue