initial commit

This commit is contained in:
Jeffrey Phillips Freeman 2023-10-08 01:35:17 -04:00
commit 1afe272ca0
Signed by: freemo
GPG key ID: AD914585C9406B6A
120 changed files with 35905 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
.bundle
.hugo
.DS_Store
.sass-cache
.gist-cache
.pygments-cache
.env
_deploy
public/
data/
static/images/twitter-*
dist
hugo
sass.old
source.old
source/_site
source/_stash
source/stylesheets/screen.css
vendor
node_modules
resources/
.vercel
auth.json
content/en/2019-10-20-podroll.markdown
static/javascripts/comlink

33
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,33 @@
default:
image: node:18.17.1
deploy_preview:
stage: deploy
except:
- master
- develop
script:
- npm install --global vercel
- vercel pull --yes --environment=preview --token="${VERCEL_TOKEN}" -S flear
- vercel build --token="${VERCEL_TOKEN}" -S flear
- vercel deploy --prebuilt --token="${VERCEL_TOKEN}" -S flear
deploy_development:
stage: deploy
only:
- develop
script:
- npm install --global vercel
- vercel pull --yes --environment=development --token="${VERCEL_TOKEN}" -S flear
- vercel build --token="${VERCEL_TOKEN}" -S flear
- vercel deploy --prebuilt --token="${VERCEL_TOKEN}" -S flear
deploy_production:
stage: deploy
only:
- master
script:
- npm install --global vercel
- vercel pull --yes --environment=production --token="${VERCEL_TOKEN}" -S flear
- vercel build --prod --token="${VERCEL_TOKEN}" -S flear
- vercel deploy --prebuilt --prod --token="${VERCEL_TOKEN}" -S flear

0
.hugo_build.lock Normal file
View file

17
.nowignore Normal file
View file

@ -0,0 +1,17 @@
.bundle
.hugo
.DS_Store
.sass-cache
.gist-cache
.pygments-cache
_deploy
public
dist
hugo
sass.old
source.old
source/_site
source/_stash
source/stylesheets/screen.css
vendor
node_modules

201
LICENSE Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

25
README.md Normal file
View file

@ -0,0 +1,25 @@
## Generate Activity Pub key
```bash
npm install ts-node typescript '@types/node'
./node_modules/.bin/ts-node --esm generateKeys.mts
```
## Setup firebase
Setup the things
## Setup Vercel
Setup the things
## Trigger post deploy
```
curl -G -X POST --data-urlencode token="<token>" https://flear.org/send-note
```
# License
This project (excluding post content itself) is released under the Apache License v2

View file

@ -0,0 +1,154 @@
import { VercelRequest, VercelResponse } from "@vercel/node";
import * as admin from 'firebase-admin';
const escapeHTML = (str: string): string => str.replace(/[<>'"]/g,
tag => {
const escapedText = {
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;'
}[tag];
return escapedText || tag;
}
);
const stripHTML = (str: string): string => str.replace(/<[^>]+>/g, '');
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
if (!admin.default.apps?.length) {
admin.default.initializeApp({
credential: admin.default.credential.cert({
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
})
});
}
const db = admin.default.firestore();
export default async function (req: VercelRequest, res: VercelResponse) {
const { body, query, method, headers } = req;
if (method != "GET") res.status(404).end("Not found");
res.statusCode = 200;
res.setHeader("Content-Type", `text/html`);
const url: string = (query.url instanceof Array) ? query.url[0] : query.url;
const idAsUrl = url.replace(/\//g, "_");
if (url == null) {
res.status(404).end("Not found");
return;
}
const likes = await db.collection("likes").doc(idAsUrl).collection("messages");
const announces = await db.collection("announces").doc(idAsUrl).collection("messages");
const replies = await db.collection("replies").doc(idAsUrl).collection("messages");
const likesSnapshot = await likes.get();
const announcesSnapshot = await announces.get();
const repliesSnapshot = await replies.get();
const likesCount = likesSnapshot.size;
const announcesCount = announcesSnapshot.size;
const repliesCount = repliesSnapshot.size;
res.end(`<!doctype html>
<html>
<head>
<title>Interactions from around the fediverse with ${escapeHTML(url)}</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@media (prefers-color-scheme: dark) {
html {
color: #fefefe;
background-color: #212529;
}
a {
color: #1bcba2
}
a:visited {
color: #7ad857
}
}
body {
font-family: Helvetica,Arial,sans-serif;
font-size: 1.1em;
}
section {
clear: both;
}
a img.profile {
width: 32px;
height: 32px;
border-radius: 50% 50%;
}
ul li {
list-style: none;
float: left;
}
div.reply p a {
margin-right: 1em;
vertical-align: middle;
}
</style>
</head>
<body>
<h1>Interactions from around the fediverse</h1>
<section class="likes">
<h4>Likes (${likesCount})</h4>
${likesSnapshot.docs.map(doc => {
const { actor } = doc.data();
if (typeof actor == "string") {
return `<a href="${escapeHTML(actor)}" target="_parent" rel="nofollow">${escapeHTML(actor)}</a>`;
}
return `<a title="${escapeHTML(actor.name)}" href="${escapeHTML(actor.url)}" target="_parent" rel="nofollow"><img class="profile" src="${escapeHTML(actor.icon.url)}" alt="The profile picture of ${escapeHTML(actor.name)}"></a>`
}
).join("")}
</section>
<section class="announces">
<h4>Announces (${announcesCount})</h4>
${announcesSnapshot.docs.map(doc => {
const { actor } = doc.data();
if (typeof actor == "string") {
return `<a href="${escapeHTML(actor)}" target="_parent" rel="nofollow">${escapeHTML(actor)}</a>`;
}
return `<a title="${escapeHTML(actor.name)}" href="${escapeHTML(actor.url)}" target="_parent" rel="nofollow"><img class="profile" src="${escapeHTML(actor.icon.url)}" alt="The profile picture of ${escapeHTML(actor.name)}"></a>`
}
).join("")}
</section>
<section class="replies">
<h4>Replies (${repliesCount})</h4>
${repliesSnapshot.docs.map(doc => {
const { actor, object } = doc.data();
if (typeof actor == "string") {
return `<div><a href="${escapeHTML(actor)}" target="_parent" rel="nofollow">${escapeHTML(actor)}</a> wrote: <blockquote>${escapeHTML(stripHTML(object.content))}</blockquote></div>`;
}
return `<div class="reply">
<p><a title="${escapeHTML(actor.name)}" href="${escapeHTML(actor.url)}" target="_parent" rel="nofollow"><img class="profile" src="${escapeHTML(actor.icon.url)}" alt="The profile picture of ${escapeHTML(actor.name)}"></a>${escapeHTML(actor.name)} wrote: <blockquote>${escapeHTML(stripHTML(object.content))}</blockquote></p>
</div>`
}
).join("")}
</section>
</body>
</html>`);
}

39
api/activitypub/actor.ts Normal file
View file

@ -0,0 +1,39 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function (req: VercelRequest, res: VercelResponse) {
const { headers } = req;
if ("accept" in headers) {
const accept = headers["accept"];
if (accept != null && accept.split(",").indexOf("text/html") > -1) {
return res.redirect(302, "https://flear.org/").end();
}
}
res.statusCode = 200;
res.setHeader("Content-Type", `application/activity+json`);
res.json({
"@context": ["https://www.w3.org/ns/activitystreams", { "@language": "en- GB" }],
"type": "Person",
"id": "https://flear.org/flear",
"outbox": "https://flear.org/outbox",
"following": "https://flear.org/following",
"followers": "https://flear.org/followers",
"inbox": "https://flear.org/inbox",
"preferredUsername": "flear",
"name": "FLEAR (Free & Libre Engineers for Amateur Radio)",
"summary": "FLEAR is non-profit promoting open-source and open-standards in Amateur Radio",
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://flear.org/images/logo.png"
},
"publicKey": {
"@context": "https://w3id.org/security/v1",
"@type": "Key",
"id": "https://flear.org/flear#main-key",
"owner": "https://flear.org/flear",
"publicKeyPem": process.env.ACTIVITYPUB_PUBLIC_KEY
}
});
}

View file

@ -0,0 +1,7 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function (req: VercelRequest, res: VercelResponse) {
res.statusCode = 200;
res.setHeader("Content-Type", `application/jrd+json`);
res.end('ok');
};

View file

@ -0,0 +1,32 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import * as admin from 'firebase-admin';
if (!admin.default.apps.length) {
admin.default.initializeApp({
credential: admin.default.credential.cert({
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
})
});
}
const db = admin.default.firestore();
export default async function (req: VercelRequest, res: VercelResponse) {
const collection = db.collection('followers');
const actors = await collection.select("actor").get();
const output = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://qoto.org/users/flear/following?page=1",
"type": "OrderedCollectionPage",
"totalItems": actors.docs.length,
"orderedItems": actors.docs.map(item=>item.get("actor"))
}
res.statusCode = 200;
res.setHeader("Content-Type", `application/activity+json`);
res.json(output);
};

View file

@ -0,0 +1,7 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function (req: VercelRequest, res: VercelResponse) {
res.statusCode = 200;
res.setHeader("Content-Type", `application/activity+json`);
res.end('ok');
};

340
api/activitypub/inbox.ts Normal file
View file

@ -0,0 +1,340 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { AP } from 'activitypub-core-types';
import { CoreObject, EntityReference } from 'activitypub-core-types/lib/activitypub/index.js';
import * as admin from 'firebase-admin';
import type { Readable } from 'node:stream';
import { v4 as uuid } from 'uuid';
import { fetchActorInformation } from '../../lib/activitypub/utils/fetchActorInformation.js';
import { parseSignature } from '../../lib/activitypub/utils/parseSignature.js';
import { sendSignedRequest } from '../../lib/activitypub/utils/sendSignedRequest.js';
import { verifySignature } from '../../lib/activitypub/utils/verifySignature.js';
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
if (!admin.default.apps.length) {
admin.default.initializeApp({
credential: admin.default.credential.cert({
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
})
});
}
const db = admin.default.firestore();
async function buffer(readable: Readable) {
const chunks = [];
for await (const chunk of readable) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
export default async function (req: VercelRequest, res: VercelResponse) {
const { body, query, method, url, headers } = req;
res.statusCode = 200;
res.setHeader("Content-Type", `application/activity+json`);
// Verify the message some how.
const buf = await buffer(req);
const rawBody = buf.toString('utf8');
const message = <AP.Activity>JSON.parse(rawBody);
if (message.type == "Delete") {
// Ignore deletes for now.
return res.end("delete")
}
console.log(message);
const signature = parseSignature(req);
const actorInformation = await fetchActorInformation(signature.keyId);
const signatureValid = verifySignature(signature, actorInformation.publicKey);
if (signatureValid == null || signatureValid == false) {
console.log("invalid signature");
res.end('invalid signature');
return;
}
if (actorInformation != null) {
await saveActor(actorInformation);
// Add the actor information to the message so that it's saved directly.
message.actor = actorInformation;
}
console.log(message.type);
// We should check the digest.
if (message.type == "Follow" && actorInformation != null) {
// We are following.
const acceptRequest = await saveFollow(<AP.Follow>message, actorInformation);
res.end("ok");
const actorInbox = new URL(<URL>actorInformation.inbox);
const response = await sendSignedRequest(actorInbox, acceptRequest);
console.log("Following result", response.status, response.statusText, await response.text());
return;
}
if (message.type == "Like") {
await saveLike(<AP.Like>message);
res.end("ok");
return;
}
if (message.type == "Announce") {
await saveAnnounce(<AP.Announce>message);
res.end("ok");
return;
}
if (message.type == "Create") {
console.log("Message type Create")
// Someone is sending us a message.
const createMessage = <AP.Create>message;
if (createMessage == null || createMessage.id == null) return;
if (createMessage.object == null) return;
// We only interested in Replies - that is a "note" with a "replyTo"
const createObject = <CoreObject>createMessage.object
if (createObject.type == "Note" && createObject.inReplyTo != undefined) {
await saveReply(<AP.Create>createMessage);
}
res.end("ok");
return;
}
if (message.type == "Undo") {
// Undo a follow.
const undoObject: AP.Undo = <AP.Undo>message;
if (undoObject == null || undoObject.id == null) return;
if (undoObject.object == null) return;
if ("actor" in undoObject.object == false) return;
if ((<CoreObject>undoObject.object).type == "Follow") {
await removeFollow(<AP.Follow>undoObject);
}
if ((<CoreObject>undoObject.object).type == "Like") {
await removeLike(<AP.Like>undoObject);
}
if ((<CoreObject>undoObject.object).type == "Announce") {
await removeAnnounce(<AP.Announce>undoObject);
}
res.end("ok");
return;
}
if (message.type == "Update") {
// TODO: We need to update the messages
console.log("Update message", message);
res.end("ok");
return;
}
res.status(401).end("Unknown message type");
};
const getActorId = (actor: AP.EntityReference): string => {
if (typeof actor == "string") {
return actor;
}
else if (actor instanceof URL) {
return actor.toString()
}
else {
return (actor.id || "").toString();
}
}
async function removeFollow(message: AP.Follow) {
// If from Mastodon - someone unfollowed me, we need to delete it from the store.
const docId = getActorId(<EntityReference>message.actor).replace(/\//g, "_");
console.log("DocId to delete", docId);
const res = await db.collection('followers').doc(docId).delete();
console.log("Deleted", res);
}
async function removeLike(message: AP.Like) {
// If from Mastodon - someone un-liked the post. We need to delete it from the store.
const doc = message.object?.object.toString().replace(/\//g, "_");
const actorId = message.object?.id.toString().replace(/\//g, "_");
console.log(`Attempting to delete Like ${actorId} on ${doc}`);
const res = await db.collection('likes').doc(doc).collection('messages').doc(actorId).delete();
console.log(`Deleted Like ${actorId} on ${doc}`, res);
}
async function removeAnnounce(message: AP.Announce) {
// If from Mastodon - someone un-liked the post. We need to delete it from the store.
const doc = message.object?.object.toString().replace(/\//g, "_");
const actorId = message.object?.id.toString().replace(/\//g, "_");
console.log(`Attempting to delete Announce ${actorId} on ${doc}`);
const res = await db.collection('announces').doc(doc).collection('messages').doc(actorId).delete();
console.log(`Deleted Announce ${actorId} on ${doc}`, res);
}
// Save the Actor objects so that we have a cache of them.
// Note: We will be embeddeding the actor information in the messages for saving directly so we can do less reads.
async function saveActor(message: AP.Actor) {
if (message.id == null) return;
const collection = db.collection('actors');
const actorId = message.id.toString().replace(/\//g, "_");
const actorDocRef = collection.doc(actorId);
// Create the follow;
await actorDocRef.set(message); // Always update the actor.
}
async function saveFollow(message: AP.Follow, actorInformation: AP.Actor) {
if (message.id == null) return;
const collection = db.collection('followers');
const actorID = getActorId(<EntityReference>message.actor).toString();
const followDocRef = collection.doc(actorID.replace(/\//g, "_"));
const followDoc = await followDocRef.get();
if (followDoc.exists) {
console.log("Already Following");
return;
}
// Create the follow;
await followDocRef.set(message);
const guid = uuid();
const domain = 'flear.org';
const acceptRequest: AP.Accept = <AP.Accept>{
"@context": "https://www.w3.org/ns/activitystreams",
'id': `https://${domain}/${guid}`,
'type': 'Accept',
'actor': "https://flear.org/flear",
'object': message.id
};
return acceptRequest;
}
async function saveLike(message: AP.Like) {
// If from Mastodon - someone liked the post.
const collection = db.collection('likes');
// We should do some checks
// 1. TODO: in reply to is against a post that I made.
console.log("Save Like", message);
/*
We store likes as a collection of collections.
Root key is the url of my messages
Each object has a sub-collection of the specific message made by someone.
*/
const id = (<URL>message.id).toString();
const objectId = (<URL>message.object).toString();
const rootDocRef = collection.doc(objectId.replace(/\//g, "_"));
const rootDoc = await rootDocRef.get();
if (rootDoc.exists == false) {
console.log("Root doesn't exists, make it so.");
await rootDocRef.set({});
}
const messagesCollection = rootDocRef.collection('messages');
const messageDocRef = messagesCollection.doc(id.replace(/\//g, "_"));
const messageDoc = await messageDocRef.get();
if (messageDoc.exists == false) {
console.log(`Adding message "${id}" to ${objectId}`);
await messageDocRef.set(message);
}
}
async function saveAnnounce(message: AP.Announce) {
// If from Mastodon - someone boosted the post.
const collection = db.collection('announces');
// We should do some checks
// 1. TODO: in reply to is against a post that I made.
console.log("Save Announce", message);
/*
We store announces as a collection of collections.
Root key is the url of my messages
Each object has a sub-collection of the specific message made by someone.
*/
const id = (<URL>message.id).toString();
const objectId = (<URL>message.object).toString();
const rootDocRef = collection.doc(objectId.replace(/\//g, "_"));
const rootDoc = await rootDocRef.get();
if (rootDoc.exists == false) {
await rootDocRef.set({});
}
const messagesCollection = rootDocRef.collection('messages');
const messageDocRef = messagesCollection.doc(id.replace(/\//g, "_"));
const messageDoc = await messageDocRef.get();
if (messageDoc.exists == false) {
console.log(`Adding message "${id}" to ${objectId}`);
await messageDocRef.set(message);
}
}
async function saveReply(message: AP.Create) {
// If from Mastodon - someone boosted the post.
const collection = db.collection('replies');
if (message.object == undefined) return;
// We should do some checks
// 1. TODO: in reply to is against a post that I made.
console.log("Save Reply", message);
/*
We store announces as a collection of collections.
Root key is the url of my messages
Each object has a sub-collection of the specific message made by someone.
*/
const objectId = (<URL>(<CoreObject>message.object).inReplyTo).toString();
const rootDocRef = collection.doc(objectId.replace(/\//g, "_"));
const rootDoc = await rootDocRef.get();
if (rootDoc.exists == false) {
await rootDocRef.set({});
}
const messagesCollection = rootDocRef.collection('messages');
const id = (<URL>message.id).toString();
const messageDocRef = messagesCollection.doc(id.replace(/\//g, "_"));
const messageDoc = await messageDocRef.get();
if (messageDoc.exists == false) {
console.log(`Adding message "${id}" to ${objectId}`);
await messageDocRef.set(message);
}
}

20
api/activitypub/outbox.ts Normal file
View file

@ -0,0 +1,20 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { join } from 'path';
import { cwd } from 'process';
import { readFileSync } from 'fs';
/*
This returns a list of posts for the single user 'FLEAR'.
It's a GET request. This doesn't post it to anyone's timeline.
*/
export default function (req: VercelRequest, res: VercelResponse) {
// All of the outbox data is generated at build time, so just return that static file.
const file = join(cwd(), 'public', 'outbox.ajson');
const stringified = readFileSync(file, 'utf8');
res.statusCode = 200;
res.setHeader("Content-Type", `application/activity+json`);
return res.end(stringified);
};

123
api/activitypub/sendNote.ts Normal file
View file

@ -0,0 +1,123 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { AP } from 'activitypub-core-types';
import * as admin from 'firebase-admin';
import { OrderedCollection } from 'activitypub-core-types/lib/activitypub/index.js';
import { sendSignedRequest } from '../../lib/activitypub/utils/sendSignedRequest.js';
import { fetchActorInformation } from '../../lib/activitypub/utils/fetchActorInformation.js';
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
if (!admin.default.apps.length) {
admin.default.initializeApp({
credential: admin.default.credential.cert({
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
})
});
}
const db = admin.default.firestore();
/*
Sends the latest not that hasn't yet been sent.
*/
export default async function (req: VercelRequest, res: VercelResponse) {
const { body, query, method, url, headers } = req;
const { token } = query;
if (method != "GET") {
console.log("Invalid Method, must be GET");
res.status(401).end("Invalid Method, must be GET");
return;
}
const configCollection = db.collection('config');
const configRef = configCollection.doc("config");
const config = await configRef.get();
if (config.exists == false) {
// Config doesn't exist, make something
configRef.set({
"lastId": "",
"lastEpoch": ""
});
}
const configData = config.data();
let lastId = "";
if (configData != undefined) {
lastId = configData.lastId;
let lastEpoch = configData.lastEpoch;
let currentEpoch = new Date().getTime();
let elapsed = currentEpoch - lastEpoch;
if (elapsed < process.env.POLL_MILLISECONDS) {
console.log("Function called too often, doing nothing");
res.status(401).end("Function is rate limited, please wait")
return;
}
}
// Get my outbox because it contains all my notes.
const outboxResponse = await fetch('https://flear.org/outbox');
const outbox = <OrderedCollection>(await outboxResponse.json());
const followersCollection = db.collection('followers');
const followersQuerySnapshot = await followersCollection.get();
let lastSuccessfulSentId = "";
for (const followerDoc of followersQuerySnapshot.docs) {
const follower = followerDoc.data();
try {
const actorUrl = (typeof follower.actor == "string") ? follower.actor : follower.actor.id;
const actorInformation = await fetchActorInformation(actorUrl);
if (actorInformation == undefined) {
// We can't send to this actor, so skip it. We should log it.
continue;
}
const actorInbox = new URL(<URL>actorInformation.inbox);
for (const iteIdx in (<AP.EntityReference[]>outbox.orderedItems)) {
// We have to break somewhere... do it after the first.
const item = (<AP.EntityReference[]>outbox.orderedItems)[iteIdx];
console.log(`Checking ID ${item.id}, ${lastId}`);
if (item.id == `${lastId}`) {
lastSuccessfulSentId = item.id;
// We've already posted this, don't try and send it again.
console.log(`${item.id} has already been posted - don't attempt`)
break;
}
if (item.object != undefined) {
// We might not need this.
item.object.published = (new Date()).toISOString();
}
console.log(`Sending to ${actorInbox}`);
// Item will be an entity, i.e, { Create { Note } }
const response = await sendSignedRequest(actorInbox, <AP.Activity> item);
console.log(`Send result: ${actorInbox}`, response.status, response.statusText, await response.text());
// It's not been sent.
lastSuccessfulSentId = item.id; // we shouldn't really set this everytime.
//break; // At some point we might want to post more than one post, so remove this.
}
} catch (ex) {
console.log("Error", ex);
}
}
configRef.set({
"lastId": lastSuccessfulSentId,
"lastEpoch": new Date().getTime()
});
console.log("sendNode successful")
res.status(200).end("ok");
};

20
api/card.ts Normal file
View file

@ -0,0 +1,20 @@
import { ImageResponse } from '@vercel/og';
export const config = {
runtime: 'edge'
}
export default function (req: Request) {
const url = new URL(req.url);
const title = url.searchParams.get("title");
const imgUrl = url.searchParams.get("imgUrl") || "https://flear.org/images/logo.png";
const width = url.searchParams.get("width") || "800"
const height = url.searchParams.get("height") || "418";
return new ImageResponse({ "type": "div", "props": { "style": { "display": "flex", "height": "100%", "width": "100%", "alignItems": "center", "justifyContent": "center", "letterSpacing": "-.02em", "fontWeight": 700, "background": "white" }, "children": [{ "type": "div", "props": { "style": { "left": 42, "top": 42, "position": "absolute", "display": "flex", "alignItems": "center" }, "children": [{ "type": "span", "props": { "style": { "width": 24, "height": 24, "background": "black" } } }, { "type": "span", "props": { "style": { "marginLeft": 8, "fontSize": 20 }, "children": "flear.org" } }] } }, { "type": "div", "props": { "style": { "display": "flex", "flexWrap": "wrap", "justifyContent": "center", "padding": "20px 50px", "margin": "0 42px", "fontSize": 40, "width": "auto", "maxWidth": 550, "textAlign": "center", "backgroundColor": "black", "color": "white", "lineHeight": 1.4 }, "children": title } }] } },
{
width,
height
}); // 800px by 418px
}

106
api/mentions.js Normal file
View file

@ -0,0 +1,106 @@
const html = require('whatwg-flora-tmpl');
const { Readable } = require('stream');
const fetch = require('node-fetch');
const sanitize = (str) => {
if (str == null || str == undefined) return "";
return str.replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;');
};
const render = (data) => html`<html><head><title>Interactions with ${data.url}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: Helvetica,Arial,sans-serif;
font-size: 1.1em;
}
.webmentions .comments .reply img {
float: left;
margin-right: 1em;
}
img.profile.photo {
width: 32px;
height: 32px;
border-radius: 50% 50%;
}
@media (prefers-color-scheme: dark) {
html {
color: #fefefe;
background-color: #212529;
}
a {
color: #1bcba2
}
a:visited {
color: #7ad857
}
}
</style>
</head>
<body>
<div class="comments webmentions">
<h4>Likes and bookmarks</h4>
${data.filter(item => item['wm-property'] === 'like-of' || item['wm-property'] === 'bookmark-of').map(item => html`<a href="${sanitize(item.author.url)}" target="_blank"><img src="${sanitize(item.author.photo)}" alt="${sanitize(item.author.name)}" class="profile photo" loading="lazy"></a>`)}
<h4>Reposts</h4>
${data.filter(item => item['wm-property'] === 'repost-of').map(item => html`<a href="${sanitize(item.author.url)}" target="_blank"><img src="${sanitize(item.author.photo)}" alt="${sanitize(item.author.name)}" class="profile photo" loading="lazy"></a>`)}
<h4>Comments and Replies</h4>
<div class="comments">
${data.filter(item => item['wm-property'] === 'in-reply-to' || item['wm-property'] === 'mention-of').map(item => html`<div class="reply">
<a href="${item.url}" target="_blank"><img src="${sanitize(item.author.photo)}" alt="${sanitize(item.author.name)}" class="profile photo" loading="lazy"></a><span><a href="${sanitize(item.url)}" target="_blank">${sanitize(item.author.name)}</a></span>
<blockquote>${sanitize(item.content?.text)}</blockquote>
</div>`)}
</div>
</div>
</body></html>`;
class FromWhatWGReadableStream extends Readable {
constructor(options, whatwgStream) {
super(options);
const streamReader = whatwgStream.getReader();
const outStream = this;
function pump() {
return streamReader.read().then(({ value, done }) => {
if (done) {
outStream.push(null);
return;
}
outStream.push(value);
return pump();
});
}
pump();
}
}
module.exports = async (req, res) => {
const { url = 'https://flear.org/', count = 200 } = req.query;
const referer = req.headers.referer;
const cacheAge = 12 * 60 * 60;
const mentionsUrl = `https://webmention.io/api/mentions.jf2?per-page=${count}&target=${referer || url}`;
try {
const mentionsResponse = await fetch(mentionsUrl);
const data = await mentionsResponse.json();
data.url = referer || url;
// Add caching.
res.statusCode = 200;
res.setHeader('Content-Type', `text/html; charset=utf-8`);
res.setHeader('Cache-Control', `public,s-maxage=${cacheAge}, max-age=${cacheAge}`);
const output = await render(data.children);
const stream = new FromWhatWGReadableStream({}, output);
stream.pipe(res, { end: true });
} catch (ex) {
console.error(ex);
res.status(500).send(ex);
}
}

33
api/nodeinfo/2.1.ts Normal file
View file

@ -0,0 +1,33 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function (req: VercelRequest, res: VercelResponse) {
res.statusCode = 200;
res.setHeader("Content-Type", `application/json`);
res.json({
"version": "2.1",
"software": {
"name": "flear.org",
"repository": "https://git.qoto.org/flear/flear-site",
"homepage": "https://flear.org/",
"version": "0.0.1"
},
"protocols": [
"activitypub"
],
"services": {
"inbound": ["atom1.0", "rss2.0"],
"outbound": ["atom1.0", "rss2.0"]
},
"openRegistrations": false,
"usage": {
"users": {
"total": 1,
"activeHalfyear": 1,
"activeMonth": 1
}
},
"metadata": {
"nodeName": "flear.org"
}
});
}

View file

@ -0,0 +1,15 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function (req: VercelRequest, res: VercelResponse) {
const { resource } = req.query;
res.statusCode = 200;
res.setHeader("Content-Type", `application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"`);
res.json({
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
"href": "https://flear.org/nodeinfo/2.1"
}
]
});
}

View file

@ -0,0 +1,20 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function (req: VercelRequest, res: VercelResponse) {
const { resource } = req.query;
res.statusCode = 200;
res.setHeader("Content-Type", `application/jrd+json`);
res.end(`{
"subject": "acct:flear@flear.org",
"aliases": [
"https://qoto.org/@flear"
],
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "https://flear.org/flear"
}
]
}`);
}

5
archetypes/default.md Normal file
View file

@ -0,0 +1,5 @@
---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
---

7970
assets/css/fontawesome.css vendored Normal file

File diff suppressed because it is too large Load diff

4833
assets/css/ghost.css Normal file

File diff suppressed because it is too large Load diff

800
assets/css/glyphicons.css Normal file
View file

@ -0,0 +1,800 @@
@font-face {
font-family: 'Glyphicons Halflings';
src: url(../fonts/glyphicons-halflings-regular.eot);
src: url(../fonts/glyphicons-halflings-regular.eot?#iefix) format("embedded-opentype"), url(../fonts/glyphicons-halflings-regular.woff2) format("woff2"), url(../fonts/glyphicons-halflings-regular.woff) format("woff"), url(../fonts/glyphicons-halflings-regular.ttf) format("truetype"), url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format("svg"); }
.glyphicon {
position: relative;
top: 1px;
display: inline-block;
font-family: 'Glyphicons Halflings';
font-style: normal;
font-weight: 400;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; }
.glyphicon-asterisk:before {
content: "*"; }
.glyphicon-plus:before {
content: "+"; }
.glyphicon-eur:before, .glyphicon-euro:before {
content: "€"; }
.glyphicon-minus:before {
content: ""; }
.glyphicon-cloud:before {
content: "☁"; }
.glyphicon-envelope:before {
content: "✉"; }
.glyphicon-pencil:before {
content: "✏"; }
.glyphicon-glass:before {
content: ""; }
.glyphicon-music:before {
content: ""; }
.glyphicon-search:before {
content: ""; }
.glyphicon-heart:before {
content: ""; }
.glyphicon-star:before {
content: ""; }
.glyphicon-star-empty:before {
content: ""; }
.glyphicon-user:before {
content: ""; }
.glyphicon-film:before {
content: ""; }
.glyphicon-th-large:before {
content: ""; }
.glyphicon-th:before {
content: ""; }
.glyphicon-th-list:before {
content: ""; }
.glyphicon-ok:before {
content: ""; }
.glyphicon-remove:before {
content: ""; }
.glyphicon-zoom-in:before {
content: ""; }
.glyphicon-zoom-out:before {
content: ""; }
.glyphicon-off:before {
content: ""; }
.glyphicon-signal:before {
content: ""; }
.glyphicon-cog:before {
content: ""; }
.glyphicon-trash:before {
content: ""; }
.glyphicon-home:before {
content: ""; }
.glyphicon-file:before {
content: ""; }
.glyphicon-time:before {
content: ""; }
.glyphicon-road:before {
content: ""; }
.glyphicon-download-alt:before {
content: ""; }
.glyphicon-download:before {
content: ""; }
.glyphicon-upload:before {
content: ""; }
.glyphicon-inbox:before {
content: ""; }
.glyphicon-play-circle:before {
content: ""; }
.glyphicon-repeat:before {
content: ""; }
.glyphicon-refresh:before {
content: ""; }
.glyphicon-list-alt:before {
content: ""; }
.glyphicon-lock:before {
content: ""; }
.glyphicon-flag:before {
content: ""; }
.glyphicon-headphones:before {
content: ""; }
.glyphicon-volume-off:before {
content: ""; }
.glyphicon-volume-down:before {
content: ""; }
.glyphicon-volume-up:before {
content: ""; }
.glyphicon-qrcode:before {
content: ""; }
.glyphicon-barcode:before {
content: ""; }
.glyphicon-tag:before {
content: ""; }
.glyphicon-tags:before {
content: ""; }
.glyphicon-book:before {
content: ""; }
.glyphicon-bookmark:before {
content: ""; }
.glyphicon-print:before {
content: ""; }
.glyphicon-camera:before {
content: ""; }
.glyphicon-font:before {
content: ""; }
.glyphicon-bold:before {
content: ""; }
.glyphicon-italic:before {
content: ""; }
.glyphicon-text-height:before {
content: ""; }
.glyphicon-text-width:before {
content: ""; }
.glyphicon-align-left:before {
content: ""; }
.glyphicon-align-center:before {
content: ""; }
.glyphicon-align-right:before {
content: ""; }
.glyphicon-align-justify:before {
content: ""; }
.glyphicon-list:before {
content: ""; }
.glyphicon-indent-left:before {
content: ""; }
.glyphicon-indent-right:before {
content: ""; }
.glyphicon-facetime-video:before {
content: ""; }
.glyphicon-picture:before {
content: ""; }
.glyphicon-map-marker:before {
content: ""; }
.glyphicon-adjust:before {
content: ""; }
.glyphicon-tint:before {
content: ""; }
.glyphicon-edit:before {
content: ""; }
.glyphicon-share:before {
content: ""; }
.glyphicon-check:before {
content: ""; }
.glyphicon-move:before {
content: ""; }
.glyphicon-step-backward:before {
content: ""; }
.glyphicon-fast-backward:before {
content: ""; }
.glyphicon-backward:before {
content: ""; }
.glyphicon-play:before {
content: ""; }
.glyphicon-pause:before {
content: ""; }
.glyphicon-stop:before {
content: ""; }
.glyphicon-forward:before {
content: ""; }
.glyphicon-fast-forward:before {
content: ""; }
.glyphicon-step-forward:before {
content: ""; }
.glyphicon-eject:before {
content: ""; }
.glyphicon-chevron-left:before {
content: ""; }
.glyphicon-chevron-right:before {
content: ""; }
.glyphicon-plus-sign:before {
content: ""; }
.glyphicon-minus-sign:before {
content: ""; }
.glyphicon-remove-sign:before {
content: ""; }
.glyphicon-ok-sign:before {
content: ""; }
.glyphicon-question-sign:before {
content: ""; }
.glyphicon-info-sign:before {
content: ""; }
.glyphicon-screenshot:before {
content: ""; }
.glyphicon-remove-circle:before {
content: ""; }
.glyphicon-ok-circle:before {
content: ""; }
.glyphicon-ban-circle:before {
content: ""; }
.glyphicon-arrow-left:before {
content: ""; }
.glyphicon-arrow-right:before {
content: ""; }
.glyphicon-arrow-up:before {
content: ""; }
.glyphicon-arrow-down:before {
content: ""; }
.glyphicon-share-alt:before {
content: ""; }
.glyphicon-resize-full:before {
content: ""; }
.glyphicon-resize-small:before {
content: ""; }
.glyphicon-exclamation-sign:before {
content: ""; }
.glyphicon-gift:before {
content: ""; }
.glyphicon-leaf:before {
content: ""; }
.glyphicon-fire:before {
content: ""; }
.glyphicon-eye-open:before {
content: ""; }
.glyphicon-eye-close:before {
content: ""; }
.glyphicon-warning-sign:before {
content: ""; }
.glyphicon-plane:before {
content: ""; }
.glyphicon-calendar:before {
content: ""; }
.glyphicon-random:before {
content: ""; }
.glyphicon-comment:before {
content: ""; }
.glyphicon-magnet:before {
content: ""; }
.glyphicon-chevron-up:before {
content: ""; }
.glyphicon-chevron-down:before {
content: ""; }
.glyphicon-retweet:before {
content: ""; }
.glyphicon-shopping-cart:before {
content: ""; }
.glyphicon-folder-close:before {
content: ""; }
.glyphicon-folder-open:before {
content: ""; }
.glyphicon-resize-vertical:before {
content: ""; }
.glyphicon-resize-horizontal:before {
content: ""; }
.glyphicon-hdd:before {
content: ""; }
.glyphicon-bullhorn:before {
content: ""; }
.glyphicon-bell:before {
content: ""; }
.glyphicon-certificate:before {
content: ""; }
.glyphicon-thumbs-up:before {
content: ""; }
.glyphicon-thumbs-down:before {
content: ""; }
.glyphicon-hand-right:before {
content: ""; }
.glyphicon-hand-left:before {
content: ""; }
.glyphicon-hand-up:before {
content: ""; }
.glyphicon-hand-down:before {
content: ""; }
.glyphicon-circle-arrow-right:before {
content: ""; }
.glyphicon-circle-arrow-left:before {
content: ""; }
.glyphicon-circle-arrow-up:before {
content: ""; }
.glyphicon-circle-arrow-down:before {
content: ""; }
.glyphicon-globe:before {
content: ""; }
.glyphicon-wrench:before {
content: ""; }
.glyphicon-tasks:before {
content: ""; }
.glyphicon-filter:before {
content: ""; }
.glyphicon-briefcase:before {
content: ""; }
.glyphicon-fullscreen:before {
content: ""; }
.glyphicon-dashboard:before {
content: ""; }
.glyphicon-paperclip:before {
content: ""; }
.glyphicon-heart-empty:before {
content: ""; }
.glyphicon-link:before {
content: ""; }
.glyphicon-phone:before {
content: ""; }
.glyphicon-pushpin:before {
content: ""; }
.glyphicon-usd:before {
content: ""; }
.glyphicon-gbp:before {
content: ""; }
.glyphicon-sort:before {
content: ""; }
.glyphicon-sort-by-alphabet:before {
content: ""; }
.glyphicon-sort-by-alphabet-alt:before {
content: ""; }
.glyphicon-sort-by-order:before {
content: ""; }
.glyphicon-sort-by-order-alt:before {
content: ""; }
.glyphicon-sort-by-attributes:before {
content: ""; }
.glyphicon-sort-by-attributes-alt:before {
content: ""; }
.glyphicon-unchecked:before {
content: ""; }
.glyphicon-expand:before {
content: ""; }
.glyphicon-collapse-down:before {
content: ""; }
.glyphicon-collapse-up:before {
content: ""; }
.glyphicon-log-in:before {
content: ""; }
.glyphicon-flash:before {
content: ""; }
.glyphicon-log-out:before {
content: ""; }
.glyphicon-new-window:before {
content: ""; }
.glyphicon-record:before {
content: ""; }
.glyphicon-save:before {
content: ""; }
.glyphicon-open:before {
content: ""; }
.glyphicon-saved:before {
content: ""; }
.glyphicon-import:before {
content: ""; }
.glyphicon-export:before {
content: ""; }
.glyphicon-send:before {
content: ""; }
.glyphicon-floppy-disk:before {
content: ""; }
.glyphicon-floppy-saved:before {
content: ""; }
.glyphicon-floppy-remove:before {
content: ""; }
.glyphicon-floppy-save:before {
content: ""; }
.glyphicon-floppy-open:before {
content: ""; }
.glyphicon-credit-card:before {
content: ""; }
.glyphicon-transfer:before {
content: ""; }
.glyphicon-cutlery:before {
content: ""; }
.glyphicon-header:before {
content: ""; }
.glyphicon-compressed:before {
content: ""; }
.glyphicon-earphone:before {
content: ""; }
.glyphicon-phone-alt:before {
content: ""; }
.glyphicon-tower:before {
content: ""; }
.glyphicon-stats:before {
content: ""; }
.glyphicon-sd-video:before {
content: ""; }
.glyphicon-hd-video:before {
content: ""; }
.glyphicon-subtitles:before {
content: ""; }
.glyphicon-sound-stereo:before {
content: ""; }
.glyphicon-sound-dolby:before {
content: ""; }
.glyphicon-sound-5-1:before {
content: ""; }
.glyphicon-sound-6-1:before {
content: ""; }
.glyphicon-sound-7-1:before {
content: ""; }
.glyphicon-copyright-mark:before {
content: ""; }
.glyphicon-registration-mark:before {
content: ""; }
.glyphicon-cloud-download:before {
content: ""; }
.glyphicon-cloud-upload:before {
content: ""; }
.glyphicon-tree-conifer:before {
content: ""; }
.glyphicon-tree-deciduous:before {
content: ""; }
.glyphicon-cd:before {
content: ""; }
.glyphicon-save-file:before {
content: ""; }
.glyphicon-open-file:before {
content: ""; }
.glyphicon-level-up:before {
content: ""; }
.glyphicon-copy:before {
content: ""; }
.glyphicon-paste:before {
content: ""; }
.glyphicon-alert:before {
content: ""; }
.glyphicon-equalizer:before {
content: ""; }
.glyphicon-king:before {
content: ""; }
.glyphicon-queen:before {
content: ""; }
.glyphicon-pawn:before {
content: ""; }
.glyphicon-bishop:before {
content: ""; }
.glyphicon-knight:before {
content: ""; }
.glyphicon-baby-formula:before {
content: ""; }
.glyphicon-tent:before {
content: "⛺"; }
.glyphicon-blackboard:before {
content: ""; }
.glyphicon-bed:before {
content: ""; }
.glyphicon-apple:before {
content: ""; }
.glyphicon-erase:before {
content: ""; }
.glyphicon-hourglass:before {
content: "⌛"; }
.glyphicon-lamp:before {
content: ""; }
.glyphicon-duplicate:before {
content: ""; }
.glyphicon-piggy-bank:before {
content: ""; }
.glyphicon-scissors:before {
content: ""; }
.glyphicon-bitcoin:before {
content: ""; }
.glyphicon-btc:before {
content: ""; }
.glyphicon-xbt:before {
content: ""; }
.glyphicon-yen:before {
content: "¥"; }
.glyphicon-jpy:before {
content: "¥"; }
.glyphicon-ruble:before {
content: "₽"; }
.glyphicon-rub:before {
content: "₽"; }
.glyphicon-scale:before {
content: ""; }
.glyphicon-ice-lolly:before {
content: ""; }
.glyphicon-ice-lolly-tasted:before {
content: ""; }
.glyphicon-education:before {
content: ""; }
.glyphicon-option-horizontal:before {
content: ""; }
.glyphicon-option-vertical:before {
content: ""; }
.glyphicon-menu-hamburger:before {
content: ""; }
.glyphicon-modal-window:before {
content: ""; }
.glyphicon-oil:before {
content: ""; }
.glyphicon-grain:before {
content: ""; }
.glyphicon-sunglasses:before {
content: ""; }
.glyphicon-text-size:before {
content: ""; }
.glyphicon-text-color:before {
content: ""; }
.glyphicon-text-background:before {
content: ""; }
.glyphicon-object-align-top:before {
content: ""; }
.glyphicon-object-align-bottom:before {
content: ""; }
.glyphicon-object-align-horizontal:before {
content: ""; }
.glyphicon-object-align-left:before {
content: ""; }
.glyphicon-object-align-vertical:before {
content: ""; }
.glyphicon-object-align-right:before {
content: ""; }
.glyphicon-triangle-right:before {
content: ""; }
.glyphicon-triangle-left:before {
content: ""; }
.glyphicon-triangle-bottom:before {
content: ""; }
.glyphicon-triangle-top:before {
content: ""; }
.glyphicon-console:before {
content: ""; }
.glyphicon-superscript:before {
content: ""; }
.glyphicon-subscript:before {
content: ""; }
.glyphicon-menu-left:before {
content: ""; }
.glyphicon-menu-right:before {
content: ""; }
.glyphicon-menu-down:before {
content: ""; }
.glyphicon-menu-up:before {
content: ""; }

3728
assets/css/goblin.css Normal file

File diff suppressed because it is too large Load diff

631
assets/css/main.css Normal file
View file

@ -0,0 +1,631 @@
div.widgets {
text-align: center;
}
#mentions, #fediverse {
border: none;
width: 100%;
}
img.profile.photo {
width: 32px;
height: 32px;
border-radius: 50% 50%;
}
aside {
background-color: wheat;
color: black;
padding: 1em;
border-radius: 0.5em;
margin: 1em;
border: dashed 3px #b78529;
}
.me {
margin: auto;
text-align: left;
max-width: 720px;
}
.me div {
margin: 2em;
}
.me img {
border-radius: 50%;
float: right;
width: 20%;
}
pre {
overflow: auto;
width: 100%;
}
body.list .post header {
padding-top: 1em;
margin-bottom: 2em;
font-family: var(--font-sans)
}
body.blogpost header {
max-width: 720px;
margin: auto;
text-align: left;
padding: 0 1em;
font-family: var(--font-sans)
}
:where(:not(pre)>code) {
white-space: break-spaces;
word-break: break-word;
}
body.blogpost hr {
height: 1px;
margin-right: 2em;
margin-left: 2em;
border: 0;
background: #333;
background-image: linear-gradient(to right, #ccc, #333, #ccc);
}
body.blogpost div.draft {
border: solid red 1px;
background-color: pink;
color: black;
padding: 1em;
}
body.blogpost div.draft a {
color: blue;
}
body.blogpost header,
body.list main .post header {
background-repeat: repeat;
background-position: 50% 50%;
background-size: cover;
background-origin: padding-box;
background-attachment: scroll;
box-sizing: border-box;
}
body.blogpost header.with-background,
body.list main .post header.with-background {
height: 520px;
margin: 0;
margin-bottom: 2em;
width: 100%;
max-width: initial;
position: relative;
}
body.blogpost .byline {
margin-bottom: 1em;
padding-bottom: 1em;
border-bottom: solid 1px #e9e9e9;
font-size: 0.8em;
}
div.subscribe,
div.share {
text-decoration: none;
color: #2a2a2a;
background-color: #2196f3;
text-align: center;
letter-spacing: 0.5px;
transition: 0.2s ease-out;
cursor: pointer;
outline: 0;
border: none;
border-radius: 2px;
display: inline-block;
height: 36px;
padding: 0 1rem;
text-transform: uppercase;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12),
0 3px 1px -2px rgba(0, 0, 0, 0.2);
}
.post.moi {
background-color: var(--blue-10);
color: var(--gray-3);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 1rem;
border-radius: 1em;
}
section.post.moi {
margin: 0;
padding: 0 1em;
}
.post.moi div>img {
width: 3em;
height: 3em;
padding: 2em;
border-radius: 50%;
}
.post.moi div p {
font-weight: 600;
}
.post.moi div+div {
padding: 1em;
}
.moi-links {
display: flex;
align-items: center;
justify-content: space-evenly;
flex-wrap: wrap;
}
.post.moi div p+p {
font-weight: inherit;
font-style: italic;
}
.post.moi a.email img,
.post.moi a.rss img,
.post.moi a.twitter img,
.post.moi a.medium img {
padding: 0.5em;
width: 32px;
height: 32px;
}
body.blogpost figure img, main p img {
width: 100%;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}
body.list main.main-wrapper,
body.blogpost main.main-wrapper {
margin: auto;
display: flex;
padding: 5em 1em 5em 1em;
flex-direction: column;
justify-content: center;
max-width: 720px;
}
body.list main p,
body.blogpost main p {
margin-block-start: 1em;
}
body main section {
padding-top: 0;
text-align: left;
}
.author {
font-style: italic;
}
body.list .post .description {
padding-bottom: 1em;
}
body .menu {
font-family: var(--font-sans);
font-weight: 400;
color: #333;
color: var(--text-color);
padding: 1em;
text-align: center;
border-bottom: solid 1px #ccc;
border-bottom: solid 1px var(--block-quote-before-color);
}
body .menu .search {
max-width: 720px;
margin: auto;
padding-top: 1em;
}
body .menu .search form {
display: flex;
gap: 1em;
}
body .menu .search input[type=search] {
width: 100%;
padding: 0.5em;
font-size: 1em;
}
body .menu .menu-items {
margin: auto;
text-align: center;
max-width: 720px;
display: flex;
justify-content: space-evenly;
}
body .menu a {
color: initial;
color: var(--text-color);
}
figure {
text-align: center;
}
figure img {
width: 100%;
}
figure video {
width: 100%;
}
body.list figure {
text-align: center;
margin: 1em -1em;
padding: 0;
}
body.list figure img {
box-shadow: 0px 10px 10px -10px black;
}
body.list figure video {
box-shadow: 0px 10px 10px -10px black;
}
figcaption {
font-size: 0.7em;
font-weight: 600;
font-style: italic;
}
blockquote {
font-style: italic;
position: relative;
quotes: "\201C""\201D";
}
div.podcast,
div.video {
margin: 1em 0em;
text-align: center;
}
div.podcast audio,
div.video video {
display: block;
margin: 1em auto;
width: 100%;
}
.widgets {
display: flex;
align-items: center;
justify-content: center;
}
.webmentions .comments .reply img {
float: left;
margin-right: 1em;
}
div.subscribe,
div.share {
margin: 1em;
display: flex;
align-items: center;
}
div.subscribe a {
color: white;
filter: contrast(100);
}
div.subscribe a img {
display: block;
}
div.share {
background-color: #ff4081;
color: white;
text-align: center;
max-height: 36px;
height: 36px;
}
.share svg {
pointer-events: none;
}
div.widgets.source a {
margin: 1em;
}
div.related.pages:empty {
display: none;
}
/**************************************************************************
** Some formatting tweaks after merging everything intentional...
**************************************************************************/
code {
background-color: #f5f5f5;
color: #000; }
.modal img {
max-width: 100%; }
.navbar {
margin-bottom: 0; }
.navbar-brand {
padding: 0; }
.navbar-brand img {
height: 100%;
padding: 15px;
width: auto; }
.navbar-nav {
border-left: 1px #e7e7e7 solid; }
.navbar-nav li > a {
font-size: 13px;
font-weight: bold; }
.navbar-nav .active > a {
background-color: #f8f8f8; }
.navbar-nav .active > a:hover {
background-color: #f8f8f8; }
.navbar-nav .subscribe-button {
border-right: 1px #e7e7e7 solid;
margin-right: 10px; }
.navbar-default .navbar-nav .current-item {
color: #434343; }
.navbar-default .navbar-nav .current-item:hover {
color: #434343; }
.subheader {
border-bottom: 1px #e7e7e7 solid;
margin-bottom: 15px; }
.subheader h2 {
margin-bottom: 26px;
margin-top: 26px;
padding: 0; }
.post {
margin-bottom: 50px; }
.post img {
max-width: 100%;
padding: 4px;
display: block;
margin-left: auto;
margin-right: auto; }
.post-image {
border: 1px solid #ddd;
border-radius: 4px;
display: block;
padding: 4px;
width: 100%; }
.post-image div {
background-position: center;
background-size: cover;
height: 250px;
max-height: 350px; }
.post-header {
margin-bottom: 10px; }
.post-header h2 {
margin-bottom: 0; }
.post-meta ul li {
font-size: 13px;
padding-right: 20px; }
.post-meta a {
color: #777; }
.author-image {
background-position: center center;
background-size: cover;
border-radius: 4px;
display: inline-block;
height: 18px;
vertical-align: top;
width: 18px; }
.share {
margin-bottom: 30px; }
.share a {
color: #aaa;
transition: .5s all; }
.share a:hover, .share a:focus {
text-decoration: none;
transition: .5s all; }
.share a.share-twitter:hover {
color: #55acee; }
.share a.share-facebook:hover {
color: #3b5998; }
.share a.share-google-plus:hover {
color: #dc4e41; }
.subscribe-panel {
margin: auto;
min-width: 300px;
width: 30%; }
.site-footer {
font-size: 13px;
margin-bottom: 30px; }
.site-footer hr {
margin-bottom: 5px; }
.site-footer p {
margin: 0; }
/**************************************
** Everything below here are my additions
***************************************/
/* tabs shortcode */
#body .nav-tabs {
position: unset;
width: unset;
}
.tab-content > .tab-pane {
display: none;
}
.tab-pane {
padding: 3px 0px;
}
.tab-pane {
padding: 0px 0px;
}
.tab-content > .tab-pane.is-active {
display: block;
}
.nav-tabs {
color: #0f0f0f;
list-style-type: none;
margin: 0;
padding: 0;
overflow: hidden;
}
.nav-tabs > button {
color: #0f0f0f;
float: left;
display: block;
padding: 5px 10px;
font-size: 14px;
}
pre {
display: block;
padding: 9.5px;
margin: 0px 0px 10px;
line-height: 1;
}
.code-tabs {
margin: 10px 0px 0px 00px;
}
.nav-tabs > button.is-active, .nav-tabs > button:hover {
color: #000 !important;
background: transparent;
}
.ButtonTabs > .nav-tabs {
border: none;
}
.ButtonTabs > .nav-tabs > .button,
.ButtonTabs > .nav-tabs > .button.is-active {
box-shadow: none;
}
/* highlight styles */
.highlight {
background-color: black;
}
.highlight > pre > code {
color: #ffffff;
text-shadow: none;
}
.code-header {
font-size: 1em;
}
/* header button */
.navbar__items > .button, .hero .button {
font-size: 0.8em;
}
.nav-tabs > .button {
padding: 2px 5px 5px 5px;
}
/* misc fixes */
.main-wrapper {
margin-top: 4em;
}
/* .ButtonTabs > .nav-tabs > .button {
margin-bottom: 1px;
} */
section > .container > .row > .col {
margin-top: 1em;
margin-bottom: 1em;
}
.container .row .menu {
padding: 0;
border: solid 1px #ccc;
text-align: left;
}
.hero .button {
margin: 0;
}
.navbar > .navbar__inner > .navbar__items--right {
padding-right: 1em;
}
@media (max-width:490px) {
.navbar > .navbar__inner > .navbar__items--left {
display: none;
}
}
@media (max-width:600px) {
.navbar > .navbar__inner > .navbar__items--right > .button {
display: none;
}
.container .row .col {
flex: none;
}
}
/* Adjusts vertical distance for microblog dynamically */
.microblog {
display: flex;
flex-direction: column;
height: 80%;
}
.fill-vert {
height: 100%;
}
.microblog .more {
padding-right: 1em;
padding-bottom: 1em;
}
@media (max-width:992px) {
.side-menu {
width: 100%;
}
}

1794
assets/css/primer.css Normal file

File diff suppressed because it is too large Load diff

154
assets/javascripts/head.js Normal file
View file

@ -0,0 +1,154 @@
(function (i, s, o, g, r, a, m) {
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date(); a = s.createElement(o),
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');
ga('create', 'UA-114468-20', 'auto');
ga('send', 'pageview');
if ('serviceWorker' in navigator) {
// Killing off all known SW for this site.
caches.keys().then((cacheKeys) => Promise.all(cacheKeys.map((key) => caches.delete(key))));
navigator.serviceWorker.getRegistrations().then(function (registrations) {
for (let registration of registrations) {
registration.unregister();
}
});
}
const deferLoadIframe = (iframe) => {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const src = iframe.getAttribute("data-src");
iframe.setAttribute("src", src);
observer.unobserve(iframe);
}
});
});
observer.observe(iframe);
}
window.addEventListener("load", function () {
var iframes = document.getElementsByTagName("iframe");
for (var i = 0; i < iframes.length; i++) {
var ifr = iframes[i];
if (ifr.hasAttribute("data-src")) {
if ('IntersectionObserver' in window) {
deferLoadIframe(ifr);
}
else {
var src = ifr.getAttribute("data-src");
ifr.setAttribute("src", src);
}
}
}
var shareButtons = document.querySelectorAll('div.share');
for (var shareButton of shareButtons) {
shareButton.addEventListener("click", function (event) {
event.preventDefault();
var shareUrl = event.target.getAttribute('url') || '';
var shareTitle = event.target.getAttribute('title') || '';
if (navigator.share) {
navigator.share({
url: shareUrl,
title: shareTitle,
text: shareTitle
}).then(function () { ga('send', 'event', 'share', 'success'); },
function (error) { ga('send', 'event', 'share', 'error', error); });
} else {
var windowOptions = 'scrollbars=yes,resizable=yes,toolbar=no,location=yes,width=520,height=420';
var twitterUrl = 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(shareTitle) + '&url=' + encodeURIComponent(shareUrl) + '&via=FLEAR';
window.open(twitterUrl, 'intent', windowOptions);
}
});
}
});
window.onbeforeinstallprompt = function (e) {
e.preventDefault();
ga('send', 'event', 'install', 'prompt');
};
/**********************************************************
** Handles light/dark switcher
***********************************************************/
function t(t) {
document.documentElement.setAttribute("data-theme", t)
}
function e() {
var t = null;
try {
t = localStorage.getItem("theme")
} catch (t) {}
return t
}
var n = window.matchMedia("(prefers-color-scheme: dark)");
n.addListener((function(n) {
null === e() && t(n.matches ? "dark" : "")
}));
var a = e();
null !== a ? t(a) : n.matches && t("dark")
/**********************************************************
** Handles tab short codes
***********************************************************/
$(document).ready(function () {
$('.tab-content').find('.tab-pane').each(function (idx, item) {
var navTabs = $(this).closest('.code-tabs').find('.nav-tabs'),
title = $(this).attr('title');
//navTabs.append('<li><a href="#">' + title + '</a></li');
navTabs.append('<button class="button button--info">' + title + '</button>');
});
updateCurrentTab()
$('.nav-tabs button').click(function (e) {
e.preventDefault();
var tab = $(this),
tabIndex = tab.index(),
tabPanel = $(this).closest('.code-tabs'),
tabPane = tabPanel.find('.tab-pane').eq(tabIndex);
tabPanel.find('.is-active').removeClass('is-active');
tab.addClass('is-active');
tabPane.addClass('is-active');
// Store the number of config language selected in users' localStorage
if(window.localStorage) {
window.localStorage.setItem("configLangPref", tabIndex + 1)
}
// After click update here not only selected part of code but others
updateCurrentTab()
});
function updateCurrentTab() {
var holder = '.nav-tabs button'
// By default current tab number is 1
var tabNumber = 1
// Get saved tab number
if (window.localStorage.getItem('configLangPref')) {
tabNumber = window.localStorage.getItem('configLangPref')
}
// Remove 'active' code to avoid multiple examples of code
$('.nav-tabs button').closest('.code-tabs').find('.is-active').removeClass('is-active');
// Set 'active' state to current li(language) and div(code) by tabNumber
$('.code-tabs div.nav-tabs').find("button:nth-of-type(" + tabNumber + ")" ).addClass('is-active');
$('.code-tabs .tab-content').find("div:nth-of-type(" + tabNumber + ")").addClass('is-active');
}
});

2
assets/javascripts/jquery.js vendored Normal file

File diff suppressed because one or more lines are too long

4
config.json Normal file
View file

@ -0,0 +1,4 @@
{
"_": "This is your Now config file. For more information see the global configuration documentation: https://vercel.com/docs/configuration#global",
"collectMetrics": true
}

73
config.toml Normal file
View file

@ -0,0 +1,73 @@
baseurl = "https://flear.org/"
languageCode = "en-us"
title = "Free and Libre Engineers for Amateur Radio"
Copyright = "FLEAR 2023"
paginate = 20
pygmentsCodeFences = true
pygmentsCodeFencesGuessSyntax = true
pygmentsStyle = "monokai"
DefaultContentLanguage = "en"
defaultContentLanguageInSubdir = false
[services.rss]
limit = 50
[permalinks]
post = "/:title/"
[author]
name = "FLEAR"
email = "flear@flear.org"
[params]
author = "FLEAR"
description = "Free and Libre Engineers for Amateur Radio"
analyticsid = "UA-114468-20"
[taxonimies]
tag = "tags"
[mediaTypes]
[mediaTypes."text/javascript"]
suffixes = ["js", "mjs"]
[mediaTypes."application/activity+json"]
suffixes = ["ajson"]
[outputFormats]
[outputFormats.ACTIVITY]
mediaType = "application/activity+json"
notAlternative = true
baseName = "activity"
[outputFormats.ACTIVITY_OUTBOX]
mediaType = "application/activity+json"
notAlternative = true
baseName = "outbox"
[outputs]
home = ["HTML", "RSS", "ACTIVITY", "ACTIVITY_OUTBOX"]
section = ["HTML", "RSS"]
page = ["HTML"]
[related]
# Only include matches with rank >= threshold. This is a normalized rank between 0 and 100.
threshold = 10
# To get stable "See also" sections we, by default, exclude newer related pages.
includeNewer = true
# Will lower case keywords in both queries and in the indexes.
toLower = false
[[related.indices]]
name = "tags"
weight = 100
[markup.goldmark.parser]
autoHeadingID = true
[markup.goldmark.extensions]
typographer = false
[markup.goldmark.renderer]
unsafe = true

94
content/_index.md Normal file
View file

@ -0,0 +1,94 @@
---
date: '2023-10-06T06:54:34'
title: Welcome
draft: false
---
{{< titled-side "FLEAR" "Free and Libre Engineers for Amateur Radio" "Learn More" "/about" "Follow our GitLab " "https://git.qoto.org/flear" >}}
{{< example "This is the title for the example" >}}
{{< highlight bash >}}
console.log("hello tabs");
System.out.println("hello tabs");
System.out.println("hello tabs");
System.out.println("hello tabs");
System.out.println("hello tabs");
System.out.println("hello tabs");
{{< /highlight >}}
{{< /example >}}
{{< /titled-side >}}
{{< seperator >}}
{{< titled "For Developers" "Some description for developers" >}}
{{< tabs "title here" >}}
{{< tab "Java" >}}
{{< highlight bash >}}
System.out.println("hello tabs");
System.out.println("hello tabs");
System.out.println("hello tabs");
System.out.println("hello tabs");
{{< /highlight >}}
{{< /tab >}}
{{< tab "JavaScript" >}}
{{< highlight bash >}}
console.log("hello tabs");
System.out.println("hello tabs");
System.out.println("hello tabs");
System.out.println("hello tabs");
System.out.println("hello tabs");
System.out.println("hello tabs");
{{< /highlight >}}
{{< /tab >}}
{{< /tabs >}}
{{< /titled >}}
{{< cards "Mission statement" "Our mission to the community" >}}
{{< card-row >}}
{{<card "Open-source" "https://foss.com" "@digipex/digipex" "1 *" >}}
We are commited to open source!
{{< / card >}}
{{<card "Open standards" "https://git.qoto.org/digipex/ax25" "@digipex/digipex" "1 *" >}}
We are commited to open standards
{{< / card >}}
{{<card "Community Growth" "https://git.qoto.org/digipex/kiss-TNC" "@digipex/kiss-tnc" "1 *" >}}
Growing the community
{{< / card >}}
{{< / card-row >}}
{{< card-row >}}
{{<card "Security" "https://git.qoto.org/digipex/apex" "@digipex/apex" "1 *" >}}
Securing the airwaves
{{< / card >}}
{{<card "Preserving the Past" "https://git.qoto.org/digipex/apex" "@digipex/apex" "1 *" >}}
Preserving historic modes and devices
{{< / card >}}
{{<card "Advancing Technology" "https://git.qoto.org/digipex/apex" "@digipex/apex" "1 *" >}}
Advancing the state of HAM Radio
{{< / card >}}
{{< / card-row >}}
{{< / cards >}}
{{< cards "Open source" "Watch the releases of each repo to stay up to date on new features" >}}
{{< card-row >}}
{{<card "Digipex" "https://git.qoto.org/digipex/digipex" "@digipex/digipex" "1 *" >}}
An APRS and APXP implementation in Ruby
{{< / card >}}
{{<card "AX.25" "https://git.qoto.org/digipex/ax25" "@digipex/digipex" "1 *" >}}
An ax.25 implementation.
{{< / card >}}
{{<card "KISS TNC" "https://git.qoto.org/digipex/kiss-TNC" "@digipex/kiss-tnc" "1 *" >}}
A ruby library for KISS used on TNC.
{{< / card >}}
{{< / card-row >}}
{{< card-row >}}
{{<card "APEX" "https://git.qoto.org/digipex/apex" "@digipex/apex" "1" "*" >}}
An APRS and APXP implementation in Pythong
{{< / card >}}
{{< / card-row >}}
{{< / cards >}}
{{< info-buttons "Learn More" "/about" "Follow our GitLab " "https://git.qoto.org/flear" >}}

View file

@ -0,0 +1,17 @@
---
slug: activitypub-support-added
date: '2023-10-05T22:36:20.122Z'
title: ActivityPub Support Added
tags:
- administration
draft: false
---
We have added ActivityPub support for this website!
If you would like to follow this blog from Mastodon or anywhere in the Fediverse
just follow the handle `@flear@flear.org` and you will get new posts to this
site directly in your feed.
In addition if you comment on one of our posts directly from your feed your
comments and likes will show on the page for that article.

View file

@ -0,0 +1,12 @@
---
slug: flear-goes-live
date: '2023-10-05T02:44:49'
title: FLEAR Goes Live
tags:
- administration
draft: false
---
FLEAR has officially launched!
More news to come soon.

View file

@ -0,0 +1,10 @@
---
slug: sending-notes-added
date: '2023-10-05T15:27:55'
title: Sending Notes Added
tags:
- administration
draft: false
---
We have now added full support for sending notes when new posts are availible!

View file

@ -0,0 +1,9 @@
---
slug: digipex
date: '2023-10-05T22:36:20.122Z'
title: "Digipex"
tags: ['APRS', 'APXP']
description: "A next-generation APRS suite implementing APXP."
---
Information on the Digipex project comming soon.

View file

@ -0,0 +1,63 @@
---
title: QOTO
date: '2023-10-05T22:36:10.122Z'
type: entry
slug: qoto
tags: ['Incubator']
---
QOTO aims to provide a community where our users do not fear being punished for their personal opinions. We do not allow people to disseminate ideologies that are abusive or violent towards others. Demonstrating support for or defending ideologies known to be violent or hateful is a bannable offense. This includes, but is not limited to: racial supremacy, anti-LGBTQ or anti-cis-gender/anti-straight, pro-genocide, child abuse or child pornography, etc. While we recognize questions and conversation regarding these topics are essential for a STEM community, in general, doing so in bad faith will result in immediate expulsion.
All discussions between mods are public, as we believe that transparency is the best way to show everyone how we put words into practice.
Additional services:
* [QOTO Groups](https://groups.qoto.org) - a server devoted to moderated groups on the Fediverse.
* [NextCloud](https://cloud.qoto.org)
* [GitLab](https://git.qoto.org)
* GitLab pages (including user hosted pages) - https://*.qoto.io
* [FunkWhale](https://audio.qoto.org)
* [PeerTube](https://video.qoto.org)
* [Discourse](https://discourse.qoto.org)
* [Matrix chat](https://element.qoto.org)
Unique Features
* Inline math Latex support - Use \ ( and \ ) for inline LaTeX, and \ [ and \ ] for display mode.
* 65,535 character limit for toots (usually 500)
* 65,535 character limit for profile bio (usually 160)
* Full text searches - usually you can only search hashtags and usernames
* Groups Support - Accounts which represent groups (such as those from Guppe) are now tagged with a group badge and are specially rendered so the messages relayed by a group appear to be originating from the user.
* Dedicated Groups Server - The fediverses only moderated groups server open to the public.
* Group Directory - Groups are federated in the group directory which, like the federated timeline, will list all groups followed by anyone on our server.
* Markdown Posts - You now have the option to select markdown formatting when posting.
* Circles - You can now create circles with followers in it. When you make a post you can select one or more circles to privately post to and only users of those circles can see your post.
* Per-toot option allowing for local-only toots that wont federate with other servers
* Local-only toot default - You can now optionally set all toots to be local-only and prevent all your posts from federating outside of the local server. Without this setting you can still select local-only on a per-toot basis.
* Domain Subscriptions - Bring another instance's local timeline as a feed in Qoto, no need to have an account on another server again, just import their timeline here!
* Favorite Domains - Our last release allowed us to add remote timeline as a feature where you could pull up the local timeline of a remote instance as its own column. Now you can also add domains to a favorite domain list which makes it easier to access and bring up the timelines by listing them directly in the navigation panel.
* User Post Notification - Users now have a bell next to their name where the follow button is. This will send you a notification every time they makes a new post.
* Keyword Subscriptions - Create custom timelines based off keywords not just hashtags, you can even use regex for advanced matching
* Account Subscriptions - This allows you to follow a remote accounts public toots in your Home timeline without actually following them. NOTE: This will not circumvent a user's privacy settings. It only delivers the same notifications as following someone via existing RSS feeds would allow, that is, it brings the RSS feed of their public posts into your home timeline this features behaves the same as the default functionality on non-Mastodon instances such as Misskey, Friendica, and Pleroma.
* Misskey compatible quoting of toots
* Colored follow button - The follow button seen in the feed and in profiles (the one that can be optionally turned off in settings) will now be colored to indicate follower status. Gray if not followed, yellow if followed, blue if following, green if mutual follow
* Bookmarking of toots
* Support for WebP and HEIC media uploads
* Optional instance ticker banners under usernames in the timelines that tell you what server a user is on and what software it is running (ie pleorma, mastodon, misskey, etc) at a glance.
* Optionally display subscribe and follower buttons directly in your timeline feeds.
* Remote Timeline Quickview - From any post or profile there is a link to bring up the remote timeline locally, right in QOTO without needing to open a new window.
* Professionally hosted with nightly backups
* light modern theme with full width columns (not fixed)
* Several extra themes - including mastodon default and mastodon default with full width columns (not fixed)
* Read/unread notifications - Unread notifications now have a subtle blue indicator. There is also a “mark all as red” button added.
* Rich-text rendering - We now render rich-text from other servers, so servers which post in a formatting that includes things like bolding, quotes, headers, italics, underlines, etc, will render in the statuses.
* Rich-text Stripping - The user option to partially or fully turn off the rendering of rich-text.
* Registration Captcha - A captcha is now required to signup for a new account, reducing spam.
* Quote feature opt-out - As introduced earlier you can quote a toot rather than just reboost it. This is an additional button on every toot. This can now be turned off in your settings and removes the button.
* Bookmark feature opt-out - As with the quote feature we could previously bookmark posts. You can now turn off the bookmark button.
* Follow button opt-out - As mentioned earlier our added feature allowing you to add a follow button, optionally colored, into the feed can now be turned off.
* Subscribe button opt-out - As before the subscribe feature that lets you watch a users public toots without following them can now be optionally turned off.
* Favorite Tags - Similar to favorite domains this allows you to create a list of your favorite tags and have them easily accessible in the navigation panel.
[See more here](https://qoto.org/about/more)

16
content/resource/qoto.md Normal file
View file

@ -0,0 +1,16 @@
---
title: QOTO Incubator Available for FLEAR
date: '2023-10-07T23:27:52+00:00'
slug: qoto-incubator-available
type: resource
tags: ['Announcements', 'Incubator']
---
For the time being we will use [https://qoto.org](https://qoto.org) to provide
generic resources such as git, web hosting, and other services. They are an
open-source community servicing the entire STEM community.
As we slowly ramp up we may begin to move services over to our own domain and
servers. Stay tuned.
See more about [QOTO here](../entry/qoto).

20
generateKeys.mts Normal file
View file

@ -0,0 +1,20 @@
import { promisify } from 'util';
import { generateKeyPair } from 'crypto';
const generateKeyPairAsync = promisify(generateKeyPair);
const pair = await generateKeyPairAsync('rsa', {
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
})
console.log(pair.publicKey);
console.log(pair.privateKey);

View file

@ -0,0 +1,76 @@
{{ partial "top_list_generic.html" . }}
{{ $paginator := .Paginate ( where .Pages ".Draft" "!=" true )}}
<div class='row'>
<div class='col-md-8'>
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
{{ range $index, $element := $paginator.Pages }}
<article class='post'>
<header class='post-header'>
<h2 class='post-title'>
<a href="{{ .Permalink }}">{{ .Title}}</a>
{{ if .Params.link }} &mdash; <a href="{{.Params.link}}">🔗</a>{{end}}
</h2>
<section class='post-meta text-muted'>
<ul class='list-inline'>
<li>
<i class='fa fa-calendar'></i>
<time class='post-date' datetime='{{ .Date.Format "2020-09-10" }}'> {{ .Date.Format "January 2 2006" }} </time>
</li>
<li>
<a class='fa-solid fa-book-open' href='{{ .Permalink }}'>
<span class='hidden'>Book icon</span>
</a>
<a href='{{ .Permalink }}'>Reading time: {{ .ReadingTime }} minute{{ if (ne .ReadingTime 1) }}s{{ end }}</a>
</li>
<li>
<i class='fa fa-folder-open'></i>
{{ $tags := .Params.tags}}
{{ with $tags }}
{{ range . }}
<a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a>
{{ end }}
{{ end }}
</li>
</ul>
</section>
</header>
<section class='post-content'>
{{.Content }}
</section>
</article>
{{ end }}
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
</div>
<div class='col-md-4'>
<nav class='menu tags'>
<span class="menu-heading">Categories</span>
<a class='menu-item selected' data-slug='all' href='#'>All posts</a>
{{range $name, $taxonomy := .Site.Taxonomies.tags}} {{ $cnt := .Count }}
{{ with $.Site.GetPage (printf "/tags/%s" $name) }}
<a href="{{ .RelPermalink }}" class='menu-item tag' data-slug='{{$name}}' title="All pages with tag <i>{{$name}}</i>">{{$name}} ({{$cnt}})</a>
{{end}}
{{end}}
</nav>
<br>
{{ partial "microblog.html" . }}
</div>
</div>
{{ partial "bottom_list.html" . }}

45
layouts/_default/rss.xml Normal file
View file

@ -0,0 +1,45 @@
{{- $pctx := . -}}
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
{{- $pages := slice -}}
{{- if or $.IsHome $.IsSection -}}
{{- $pages = $pctx.RegularPages -}}
{{- else -}}
{{- $pages = $pctx.Pages -}}
{{- end -}}
{{- $limit := .Site.Config.Services.RSS.Limit -}}
{{- if ge $limit 1 -}}
{{- $pages = $pages | first $limit -}}
{{- end -}}
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
<rss version="2.0"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>{{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{.}} on {{ end }}{{ .Site.Title }}{{ end }}</title>
<link>{{ .Permalink }}</link>
<description>Recent content {{ if ne .Title .Site.Title }}{{ with .Title }}in {{.}} {{ end }}{{ end }}on {{ .Site.Title }}</description>
<generator>Hugo -- gohugo.io</generator>{{ with .Site.LanguageCode }}
<language>{{.}}</language>{{end}}{{ with .Site.Author.email }}
<managingEditor>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</managingEditor>{{end}}{{ with .Site.Author.email }}
<webMaster>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</webMaster>{{end}}{{ with .Site.Copyright }}
<copyright>{{.}}</copyright>{{end}}{{ if not .Date.IsZero }}
<lastBuildDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>{{ end }}
{{ with .OutputFormats.Get "RSS" }}
{{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }}
{{ end }}
{{ $notdrafts := where $pages ".Draft" "!=" true }}
{{ range (where $notdrafts "Type" "in" (slice "resource" "post" "page")) }}
<item>
<title>{{ .Title }}</title>
<link>{{ .Permalink }}</link>
<pubDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate>
{{ with .Site.Author.email }}<author>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</author>{{end}}
<guid>{{ .Permalink }}</guid>
<description>{{ .Summary | html }}</description>
<content:encoded> {{ printf "<![CDATA[" | safeHTML }}
{{- printf "%s" .Content | safeHTML }}
{{- printf "]]>" | safeHTML }}</content:encoded>
</item>
{{ end }}
</channel>
</rss>

View file

@ -0,0 +1,67 @@
{{ partial "top_single.html" . }}
<div class='row'>
<div class='col-md-8'>
<article class='post'>
<header class='post-header'>
<section class='post-meta text-muted'>
<ul class='list-inline'>
<li>
<i class='fa fa-calendar'></i>
<time class='post-date' datetime='{{ .Date.Format "2020-09-10" }}'> {{ .Date.Format "January 2 2006" }} </time>
</li>
<li>
<a class='fa-solid fa-book-open' href='{{ .Permalink }}'>
<span class='hidden'>Book icon</span>
</a>
<a href='{{ .Permalink }}'>Reading time: {{ .ReadingTime }} minute{{ if (ne .ReadingTime 1) }}s{{ end }}</a>
</li>
<li>
<i class='fa fa-folder-open'></i>
{{ $tags := .Params.tags}}
{{ with $tags }}
{{ range . }}
<a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a>
{{ end }}
{{ end }}
</li>
</ul>
</section>
</header>
<section class='post-content'>
{{ if isset .Params "toc" }}{{ .TableOfContents }}{{ end}}
{{ .Content }}
{{ partial "article_footer.html" . }}
{{ partial "comments.html" . }}
<section class='share text-center'>
<a class='share-twitter' href='https://twitter.com/intent/tweet?text={{ if .Title }}{{ .Title }}{{else}}{{.Site.Title}}{{end}}amp;url={{ .Permalink }}' onclick='window.open(this.href, &#39;twitter-share&#39;, &#39;width=550,height=235&#39;);return false;' true=''>
<span class='fa-stack fa-lg'>
<i class='fa fa-circle fa-stack-2x'></i>
<i class='fa-brands fa-twitter fa-stack-1x fa-inverse'></i>
</span>
</a>
<a class='share-facebook' href='https://www.facebook.com/sharer/sharer.php?u={{ .Permalink }}' onclick='window.open(this.href, &#39;facebook-share&#39;,&#39;width=580,height=296&#39;);return false;' true=''>
<span class='fa-stack fa-lg'>
<i class='fa fa-circle fa-stack-2x'></i>
<i class='fa-brands fa-facebook-f fa-stack-1x fa-inverse'></i>
</span>
</a>
</section>
{{ partial "comments.html" . }}
</section>
</article>
</div>
<div class='col-md-4'>
<nav class='menu tags'>
<span class="menu-heading">Recent Posts</span>
{{ $hereLink := .Permalink }}
{{ range .Site.Pages }}
{{ range .Pages }}
<a href='{{ .RelPermalink }}' class='menu-item tag{{ if (eq .Permalink $hereLink) }} selected{{ end }}' data-slug='{{ .Title}}' title="{{ .Title}}">{{ .Title}}</a>
{{ end }}
{{ end }}
</nav>
<br>
{{ partial "microblog.html" . }}
</div>
</div>
{{ partial "bottom_single.html" . }}

View file

@ -0,0 +1,30 @@
{{ partial "head.html" . }}
<body class="list">
<header class="me">
<div>
<img src="/images/logo.png">
<h1>Hello.</h1>
<h2>I am FLEAR.</h2>
<h3>A Developer Advocate for Chrome and the Open Web at Google.</h3>
</div>
</header>
<div>
<main>
{{ $data := .Data }}
{{ range $key,$value := .Data.Terms.ByCount }}
<div class="post">
<header>
<h3><a href="/{{ $data.Plural }}/{{ $value.Name | urlize }}">{{ $value.Name }}</a> ({{ $value.Count }} articles)</h3>
</header>
<div class="description">
{{ range $key1,$page := $value.WeightedPages }}
<li><a href="{{$page.Permalink}}">{{ $page.Title }}</a> - published <em>{{ .Date.Format "January 2 2006" }}</em></li>
{{ end }}
</div>
</div>
{{ end }}
</main>
</div>
</body>
</html>

69
layouts/entry/single.html Normal file
View file

@ -0,0 +1,69 @@
{{ partial "top_single.html" . }}
<div class='row'>
<div class='col-md-8'>
<article class='post'>
<header class='post-header'>
<section class='post-meta text-muted'>
<ul class='list-inline'>
<li>
<i class='fa fa-calendar'></i>
<time class='post-date' datetime='{{ .Date.Format "2020-09-10" }}'> {{ .Date.Format "January 2 2006" }} </time>
</li>
<li>
<a class='fa-solid fa-book-open' href='{{ .Permalink }}'>
<span class='hidden'>Book icon</span>
</a>
<a href='{{ .Permalink }}'>Reading time: {{ .ReadingTime }} minute{{ if (ne .ReadingTime 1) }}s{{ end }}</a>
</li>
<li>
<i class='fa fa-folder-open'></i>
{{ $tags := .Params.tags}}
{{ with $tags }}
{{ range . }}
<a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a>
{{ end }}
{{ end }}
</li>
</ul>
</section>
</header>
<section class='post-content'>
{{ if isset .Params "toc" }}{{ .TableOfContents }}{{ end}}
{{ .Content }}
{{ partial "backlinks.html" . }}
{{ partial "article_footer.html" . }}
<section class='share text-center'>
<a class='share-twitter' href='https://twitter.com/intent/tweet?text={{ if .Title }}{{ .Title }}{{else}}{{.Site.Title}}{{end}}amp;url={{ .Permalink }}' onclick='window.open(this.href, &#39;twitter-share&#39;, &#39;width=550,height=235&#39;);return false;' true=''>
<span class='fa-stack fa-lg'>
<i class='fa fa-circle fa-stack-2x'></i>
<i class='fa-brands fa-twitter fa-stack-1x fa-inverse'></i>
</span>
</a>
<a class='share-facebook' href='https://www.facebook.com/sharer/sharer.php?u={{ .Permalink }}' onclick='window.open(this.href, &#39;facebook-share&#39;,&#39;width=580,height=296&#39;);return false;' true=''>
<span class='fa-stack fa-lg'>
<i class='fa fa-circle fa-stack-2x'></i>
<i class='fa-brands fa-facebook-f fa-stack-1x fa-inverse'></i>
</span>
</a>
</section>
{{ partial "comments.html" . }}
</section>
</article>
</div>
<div class='col-md-4'>
<nav class='menu tags'>
<span class="menu-heading">Recent Resource Posts</span>
<a class='menu-item' data-slug='all' href='/resource'>All posts</a>
{{ $hereLink := .Permalink }}
{{ range (where .Site.Pages "Section" "resource") }}
{{ range .Pages }}
<a href='{{ .RelPermalink }}' class='menu-item tag{{ if (eq .Permalink $hereLink) }} selected{{ end }}' data-slug='{{ .Title}}' title="{{ .Title}}">{{ .Title}}</a>
{{ end }}
{{ end }}
</nav>
<br>
{{ partial "microblog.html" . }}
</div>
</div>
{{ partial "bottom_single.html" . }}

View file

@ -0,0 +1,16 @@
{
"@context": ["https://www.w3.org/ns/activitystreams",
{"@language": ""en-GB"}],
"type": "Person",
"id": "{{ $.Site.BaseURL }}",
"outbox": "{{ $.Site.BaseURL }}outbox",
"inbox": "{{ $.Site.BaseURL }}inbox",
"preferredUsername": "{{$.Site.Author.name}}",
"name": "{{$.Site.Author.name}} - {{$.Site.Title}}",
"summary": "{{$.Site.Params.Description}}",
"icon": {
"type":"Image",
"mediaType":"image/png",
"url": "{{ $.Site.BaseURL }}images/logo.png"
}
}

View file

@ -0,0 +1,41 @@
{{- $pctx := . -}}
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
{{- $pages := slice -}}
{{- if or $.IsHome $.IsSection -}}
{{- $pages = $pctx.RegularPages -}}
{{- else -}}
{{- $pages = $pctx.Pages -}}
{{- end -}}
{{- $limit := .Site.Config.Services.RSS.Limit -}}
{{- if ge $limit 1 -}}
{{- $pages = $pages | first $limit -}}
{{- end -}}
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "{{ $.Site.BaseURL }}outbox",
"summary": "{{$.Site.Author.name}} - {{$.Site.Title}}",
"type": "OrderedCollection",
{{ $notdrafts := where $pages ".Draft" "!=" true }}
{{ $all := where $notdrafts "Type" "in" (slice "resource" "post" "page")}}
"totalItems": {{(len $all)}},
"orderedItems": [
{{ range $index, $element := $all }}
{{- if ne $index 0 }}, {{ end }}
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "{{.Permalink}}-create",
"type": "Create",
"actor": "https://flear.org/flear",
"object": {
"id": "{{ .Permalink }}",
"type": "Note",
"content": "{{.Title}}<br><a href='{{.Permalink}}'>{{.Permalink}}</a>",
"url": "{{.Permalink}}",
"attributedTo": "https://flear.org/flear",
"to": "https://www.w3.org/ns/activitystreams#Public",
"published": {{ dateFormat "2006-01-02T15:04:05-07:00" .Date | jsonify }}
}
}
{{end}}
]
}

3
layouts/index.html Normal file
View file

@ -0,0 +1,3 @@
{{ partial "top_home.html" }}
{{ .Content }}
{{ partial "bottom_home.html" }}

91
layouts/news/list.html Normal file
View file

@ -0,0 +1,91 @@
{{ partial "top_list_news.html" . }}
{{ $paginator := .Paginate (where .Data.Pages ".Section" "news") }}
<div class='row'>
<div class='col-md-8'>
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
{{ range $index, $element := $paginator.Pages }}
<article class='post'>
<header class='post-header'>
<h2 class='post-title'>
<a href="{{ .Permalink }}">{{ .Title}}</a>
{{ if .Params.link }} &mdash; <a href="{{.Params.link}}">🔗</a>{{end}}
</h2>
<section class='post-meta text-muted'>
<ul class='list-inline'>
<li>
<i class='fa fa-calendar'></i>
<time class='post-date' datetime='{{ .Date.Format "2020-09-10" }}'> {{ .Date.Format "January 2 2006" }} </time>
</li>
<li>
<a class='fa-solid fa-book-open' href='{{ .Permalink }}'>
<span class='hidden'>Book icon</span>
</a>
<a href='{{ .Permalink }}'>Reading time: {{ .ReadingTime }} minute{{ if (ne .ReadingTime 1) }}s{{ end }}</a>
</li>
<li>
<i class='fa fa-folder-open'></i>
{{ $tags := .Params.tags}}
{{ with $tags }}
{{ range . }}
<a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a>
{{ end }}
{{ end }}
</li>
</ul>
</section>
</header>
<section class='post-content'>
{{.Content }}
</section>
</article>
{{ end }}
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
</div>
<div class='col-md-4'>
<nav class='menu tags'>
<span class="menu-heading">News Categories</span>
<a class='menu-item selected' data-slug='all' href='/news'>All posts</a>
{{ $hereSection := .Section }}
{{/* Get current taxonomy (example: tags, categories). */}}
<!-- {{ $taxonomy := .Data.Plural }} -->
{{ $taxonomy := "tags" }}
{{/* Get sections where one or more pages has one or more terms in the current taxonomy. */}}
{{ $sections := slice }}
{{ range $term, $weightedPages := index site.Taxonomies $taxonomy }}
{{ range $weightedPages }}
{{ $sections = $sections | append .CurrentSection | uniq | sort }}
{{ end }}
{{ end }}
{{/* List pages by term by section. */}}
{{ range $section := (where $sections ".Path" $hereSection) }}
{{ range $term, $weightedPages := index site.Taxonomies $taxonomy }}
{{ $termPage := site.GetPage path.Join $taxonomy $term }}
{{ with where $weightedPages "Section" $section.Path }}
<a href="{{ $termPage.RelPermalink }}" class='menu-item tag' data-slug='{{ $termPage.LinkTitle }}' title="All pages with tag <i>{{ $termPage.LinkTitle }}</i>">{{ $termPage.LinkTitle }}</a>
{{ end }}
{{ end }}
{{ end }}
</nav>
<br>
{{ partial "microblog.html" . }}
</div>
</div>
{{ partial "bottom_list.html" . }}

67
layouts/news/single.html Normal file
View file

@ -0,0 +1,67 @@
{{ partial "top_single.html" . }}
<div class='row'>
<div class='col-md-8'>
<article class='post'>
<header class='post-header'>
<section class='post-meta text-muted'>
<ul class='list-inline'>
<li>
<i class='fa fa-calendar'></i>
<time class='post-date' datetime='{{ .Date.Format "2020-09-10" }}'> {{ .Date.Format "January 2 2006" }} </time>
</li>
<li>
<a class='fa-solid fa-book-open' href='{{ .Permalink }}'>
<span class='hidden'>Book icon</span>
</a>
<a href='{{ .Permalink }}'>Reading time: {{ .ReadingTime }} minute{{ if (ne .ReadingTime 1) }}s{{ end }}</a>
</li>
<li>
<i class='fa fa-folder-open'></i>
{{ $tags := .Params.tags}}
{{ with $tags }}
{{ range . }}
<a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a>
{{ end }}
{{ end }}
</li>
</ul>
</section>
</header>
<section class='post-content'>
{{ if isset .Params "toc" }}{{ .TableOfContents }}{{ end}}
{{ .Content }}
{{ partial "article_footer.html" . }}
<section class='share text-center'>
<a class='share-twitter' href='https://twitter.com/intent/tweet?text={{ if .Title }}{{ .Title }}{{else}}{{.Site.Title}}{{end}}amp;url={{ .Permalink }}' onclick='window.open(this.href, &#39;twitter-share&#39;, &#39;width=550,height=235&#39;);return false;' true=''>
<span class='fa-stack fa-lg'>
<i class='fa fa-circle fa-stack-2x'></i>
<i class='fa-brands fa-twitter fa-stack-1x fa-inverse'></i>
</span>
</a>
<a class='share-facebook' href='https://www.facebook.com/sharer/sharer.php?u={{ .Permalink }}' onclick='window.open(this.href, &#39;facebook-share&#39;,&#39;width=580,height=296&#39;);return false;' true=''>
<span class='fa-stack fa-lg'>
<i class='fa fa-circle fa-stack-2x'></i>
<i class='fa-brands fa-facebook-f fa-stack-1x fa-inverse'></i>
</span>
</a>
</section>
{{ partial "comments.html" . }}
</section>
</article>
</div>
<div class='col-md-4'>
<nav class='menu tags'>
<span class="menu-heading">Recent News Posts</span>
<a class='menu-item' data-slug='all' href='/news'>All posts</a>
{{ $hereLink := .Permalink }}
{{ range (where .Site.Pages "Section" "news") }}
{{ range .Pages }}
<a href='{{ .RelPermalink }}' class='menu-item tag{{ if (eq .Permalink $hereLink) }} selected{{ end }}' data-slug='{{ .Title}}' title="{{ .Title}}">{{ .Title}}</a>
{{ end }}
{{ end }}
</nav>
<br>
{{ partial "microblog.html" . }}
</div>
</div>
{{ partial "bottom_single.html" . }}

View file

@ -0,0 +1,18 @@
{{ $related := .Site.RegularPages.Related . | first 5 }}
{{ with $related }}
<div class="related pages">
<h4>See Also</h4>
<ul>
{{ range . }}
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
<div class="widgets source">
<a href="https://git.qoto.org/flear/flear-site/tree/master/content/{{.File.LogicalName}}" rel="nofollow">View Source</a>
|
<a href="https://git.qoto.org/flear/flear-site/edit/master/content/{{.File.LogicalName}}" rel="nofollow">Make a correction</a>
|
<a href="https://git.qoto.org/flear/flear-site/commits/master/content/{{.File.LogicalName}}" rel="nofollow">Correction history</a>
</div>

View file

@ -0,0 +1,31 @@
{{ $re := $.File.BaseFileName }} {{ $backlinks := slice }} {{ range where
.Site.RegularPages "Type" "in" (slice "resource" "entry") }} {{ if and (findRE $re .RawContent) (not (eq
$re .File.BaseFileName)) }} {{ $backlinks = $backlinks | append . }} {{ end }}
{{ end }} {{ if gt (len $backlinks) 0 }}
<aside>
<h4>Backlinks</h4>
<div class="backlinks">
<ul>
{{ range $backlinks }}
<li class="capitalize"><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>
</aside>
{{ else }}
<aside>
<h4>No notes link to this note</h4>
</aside>
{{ end }}
<aside class="related">
{{ $related := .Site.RegularPages.Related . | complement $backlinks | first 3
-}} {{ with $related -}}
<h4>slightly related</h4>
<ul>
{{ range . -}}
<li class="capitalize"><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{ end -}}
</ul>
{{ end -}}
</aside>

View file

@ -0,0 +1 @@
{{ partial "bottom_list.html" . }}

View file

@ -0,0 +1,5 @@
</main>
{{ partial "footer.html" . }}
</div>
</body>
</html>

View file

@ -0,0 +1 @@
{{ partial "bottom_list.html" . }}

View file

@ -0,0 +1,24 @@
<script defer src="https://cdn.commento.io/js/commento.js" data-no-fonts="true"></script>
<div id="commento"></div>
<div class="comments webmentions">
<iframe id="fediverse" src="/api/activitypub-html/render.ts?url={{.Page.Permalink}}">
</iframe>
<script>
// Selecting the iframe element
const fediverseFrame = document.getElementById("fediverse");
fediverseFrame.onload = function () {
fediverseFrame.style.height = `${fediverseFrame.contentWindow.document.body.scrollHeight}px`;
}
</script>
<!--<iframe id="mentions" src="/api/mentions.js?url={{.Page.Permalink}}"></iframe>-->
<script>
// Selecting the iframe element
const mentionsFrame = document.getElementById("mentions");
if (mentionsFrame) {
mentionsFrame.onload = function () {
mentionsFrame.style.height = `${mentionsFrame.contentWindow.document.body.scrollHeight}px`;
}
}
</script>
</div>

View file

@ -0,0 +1,11 @@
<div class="draft">
<p><strong>Disclaimer</strong>. This article is a <strong>draft</strong>.</p>
<p>This article might look complete but it is completely un-developed and is a snapshot of my thoughts at
the time (which might also be a long time ago). It will be refined over time with research, education and
conversations with people in the industry.</p>
<p>There are many errors including and not limited to: spelling, grammatical, lack of ecosystem awareness
understanding and plain ignorance.</p>
<p>I am making it public to get feedback. You might completely disagree, you might agree violently. Either
way, teach me. I want to hear from you: (<a href="mailto:flear@flear.org">Email</a>, <a
href="https://twitter.com/@FLEAR_radio/">@FLEAR_radio</a>.)</p>
</div>

View file

@ -0,0 +1,17 @@
<footer class='site-footer clearfix container text-muted'>
<hr>
<div class='row'>
<div class='col-md-6'>
<a data-scroll='true' href='#top'>
<i class='fa fa-arrow-up'></i> Back to top </a>
</div>
<div class='col-md-6 text-right'>
<p>
<strong>FLEAR</strong> &copy; 2023 to present
</p>
<p>
<a href='https://git.qoto.org/flear/flear-side'>Get source</a> by <a href='https://jeffreyfreeman.me'>Jeffrey Phillips Freeman</a>
</p>
</div>
</div>
</footer>

119
layouts/partials/head.html Normal file
View file

@ -0,0 +1,119 @@
<!DOCTYPE html>
<!--[if IEMobile 7 ]><html class="iem7"><![endif]-->
<html lang="{{.Page.Lang}}">
<head lang="{{.Page.Lang}}">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://www.google-analytics.com" crossorigin>
{{ range $.Page.AllTranslations }}
<link rel="alternate" hreflang="{{.Lang}}" href="{{.Permalink}}" />{{if eq .Lang "en"}}
<link rel="alternate" hreflang="x-default" href="{{.Permalink}}" />{{end}}
{{ end }}
<title>{{if .IsPage }}{{ if .Title }}{{ .Title }}{{ end }}{{else}}{{.Site.Title}}{{end}}</title>
<link rel="canonical" href="{{ .Page.Permalink }}" />
<meta name="author" content="{{ .Site.Params.author }}">
<link rel="webmention" href="https://webmention.io/flear.org/webmention" />
<link rel="pingback" href="https://webmention.io/flear.org/xmlrpc" />
<meta name="supported-color-schemes" content="light dark">
<meta name="theme-color" content="{{ if .Params.themecolor }}#{{ .Params.themecolor}}{{ else }}#000000{{ end }}">
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image">
{{if .Params.image_header}}
<meta name="twitter:image" content="{{.Params.image_header}}">
{{else}}
{{ $tags := .Params.tags}}
{{ if .Params.social_image_url }}
{{ $socialURL := printf "https://flear.org/api/card?%s" (querify "title" .Title "imgUrl" .Params.social_image_url ) }}
<meta name="twitter:image" {{ printf "content=%q" $socialURL | safeHTMLAttr}}>
<meta property="og:image" {{ printf "content=%q" $socialURL | safeHTMLAttr}}>
{{else}}
{{ $socialURL := printf "https://flear.org/api/card?%s" (querify "title" .Title) }}
<meta name="twitter:image" {{ printf "content=%q" $socialURL | safeHTMLAttr}}>
<meta property="og:image" {{ printf "content=%q" $socialURL | safeHTMLAttr}}>
{{end}}
{{end}}
<meta name="twitter:site" content="@FLEAR_radio">
<meta name="twitter:creator" content="@FLEAR_radio">
{{ if .Title }}
<meta name="twitter:title" content="{{ .Title }} ">
{{end}}
<meta name="description" content="{{if .Description}}{{ .Description }}{{else}}{{.Site.Params.Description}}{{end}}">
<meta name="twitter:description" content="{{if .Description}}{{ .Description }}{{else}}{{.Site.Params.Description}}{{end}}">
<script defer src="/_vercel/insights/script.js"></script>
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "Person",
"name": "FLEAR",
"url": "https://flear.org/",
"sameAs": [
"https://twitter.com/FLEAR_radio",
"https://git.qoto.org/flear",
]
}
</script>
{{ if eq .Type "page" }}
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "NewsArticle",
"headline": {{.Title }},
"datePublished": "{{ .Date.Format "2006-01-02T15:04:05" }}",
"author": {
"@type": "Person",
"name": "FLEAR",
"url": "https://flear.org/"
},
"description": "{{if .Description}}{{ .Description }}{{else}}{{.Summary}}{{end}}"
}
</script>
{{ end }}
<link href="https://flear.org/flear" rel="alternate" type="application/activity+json">
<link rel="polymath" href="/polymath">
<link href="/images/logo.png" rel="icon">
<link href="/manifest.json" rel="manifest">
<link rel="home" href="{{.Site.BaseURL}}">
<style>
{{ $main := resources.Get "css/main.css" }}
{{ $goblin := resources.Get "css/goblin.css" }}
{{ $fa := resources.Get "css/fontawesome.css" }}
{{ $primer := resources.Get "css/primer.css" }}
{{ $ghost := resources.Get "css/ghost.css" }}
{{ $glyph := resources.Get "css/glyphicons.css" }}
{{ $css := slice $main $goblin $fa $primer $ghost $glyph | resources.Concat "css/bundle.css" }}
{{ $minifiedCSS := $css | minify}}
{{ $minifiedCSS.Content | safeCSS }}
header.with-background {
{{ if .Params.image_header }}background-image: url('{{ .Params.image_header}}');{{ end }}
}
</style>
{{ range .AlternativeOutputFormats -}}
<link rel="{{ .Rel }}" type="{{ .MediaType.Type }}" href="{{ .Permalink | safeURL }}">
{{ end -}}
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-V4DZ9TE0NV"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-V4DZ9TE0NV');
</script>
<script>
{{ $jqueryJS := resources.Get "javascripts/jquery.js" | minify }}
{{ $jqueryJS.Content | safeJS }}
</script>
<script>
let type='{{.Kind}}';
{{ $headJS := resources.Get "javascripts/head.js" | minify }}
{{ $headJS.Content | safeJS }}
</script>
</head>

View file

@ -0,0 +1,52 @@
<nav class="navbar navbar--light navbar--fixed-top">
<div class="navbar__inner">
<div class="navbar__items navbar__items--left navbarItems_1xGM">
<a aria-current="page" class="navbar__brand active" href="/">
<img class="navbar__logo logo--light" src="/images/logo-header-light.png" alt="FLEAR">
<img class="navbar__logo logo--dark" src="/images/logo-header-dark.png" alt="FLEAR">
</a>
<div aria-label="Navigation bar toggle" class="navbar__toggle" role="button" tabindex="0">
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" role="img" focusable="false">
<title>Menu</title>
<path stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M4 7h22M4 15h22M4 23h22"></path>
</svg>
</div>
</div>
<div class="navbar__items navbar__items--right">
<a class="navbar__item navbar__link" href="/">Home</a>
<a class="navbar__item navbar__link" href="/news">News</a>
<a class="navbar__item navbar__link" href="/projects">Projects</a>
<a class="navbar__item navbar__link" href="/resource">Resources</a>
<a class="displayOnlyInLargeViewport_16CL button button--primary" href="https://git.qoto.org/flear" style="margin:0;margin-left:15px">FLEAR GITLAB →</a>
</div>
</div>
<div role="presentation" class="navbar-sidebar__backdrop"></div>
<div class="navbar-sidebar">
<div class="navbar-sidebar__brand">
<a aria-current="page" class="navbar__brand active" href="/">
<img class="navbar__logo logo--light" src="/images/logo-header-light.png" alt="Goblin">
<img class="navbar__logo logo--dark" src="/images/logo-header-dark.png" alt="Goblin">
</a>
</div>
<div class="navbar-sidebar__items">
<div class="menu">
<ul class="menu__list">
<li class="menu__list-item">
<a class="menu__link" href="/">Home</a>
</li>
<li class="menu__list-item">
<a class="menu__link" href="/news">News</a>
</li>
<li class="menu__list-item">
<a class="menu__link" href="/projects">Projects</a>
</li>
<li class="menu__list-item">
<a class="menu__link" href="/resource">Resources</a>
</li>
<div style="margin:5px;margin-top:15px"></div>
<a class="button button--block button--primary" href="https://git.qoto.org/flear">OUR GITLAB →</a>
</ul>
</div>
</div>
</div>
</nav>

View file

@ -0,0 +1,8 @@
<div class='menu microblog'>
<span class='menu-heading'> Microblog </span>
<iframe allowfullscreen sandbox="allow-top-navigation allow-scripts allow-popups allow-popups-to-escape-sandbox" style='border-width: 0' width='100%' height="800" src="https://www.mastofeed.com/apiv2/feed?userurl=https%3A%2F%2Fqoto.org%2Fusers%2FFLEAR&theme=light&size=80&header=false&replies=false&boosts=false"></iframe>
<div class="fill-vert"></div>
<div class="more">
<a class='btn btn-default btn-m pull-right' href='https://qoto.org/@FLEAR/' style='align:right;text-align: right;align-content: right;'>More ... </a>
</div>
</div>

View file

@ -0,0 +1,6 @@
{{ partial "head.html" . }}
<body class="list h-feed">
<div>
{{ partial "menu.html" . }}
<div class="main-wrapper">
<main class="hfeed">

View file

@ -0,0 +1,12 @@
{{ partial "head.html" . }}
<body class="list h-feed">
<div>
{{ partial "menu.html" . }}
<div class="main-wrapper">
<header class='subheader'>
<div class='container'>
<h2 class='page-title'> FLEAR <small>Free & Libre Engineers for Amateur Radio</small>
</h2>
</div>
</header>
<main class="hfeed container" id="content" role="main">

View file

@ -0,0 +1,12 @@
{{ partial "head.html" . }}
<body class="list h-feed">
<div>
{{ partial "menu.html" . }}
<div class="main-wrapper">
<header class='subheader'>
<div class='container'>
<h2 class='page-title'> News <small>For current events in FLEAR and its projects. You can follow this page on the fediverse at @flear@flear.org.</small>
</h2>
</div>
</header>
<main class="hfeed container" id="content" role="main">

View file

@ -0,0 +1,12 @@
{{ partial "head.html" . }}
<body class="list h-feed">
<div>
{{ partial "menu.html" . }}
<div class="main-wrapper">
<header class='subheader'>
<div class='container'>
<h2 class='page-title'> Projects <small>Open source projects past of our incubator for Ham Radio.</small>
</h2>
</div>
</header>
<main class="hfeed container" id="content" role="main">

View file

@ -0,0 +1,12 @@
{{ partial "head.html" . }}
<body class="list h-feed">
<div>
{{ partial "menu.html" . }}
<div class="main-wrapper">
<header class='subheader'>
<div class='container'>
<h2 class='page-title'> Resources <small>services, assistance, information, and other resources to assist the FLOSS Ham.</small>
</h2>
</div>
</header>
<main class="hfeed container" id="content" role="main">

View file

@ -0,0 +1,17 @@
{{ partial "head.html" . }}
<body class="single">
<div>
{{ partial "menu.html" . }}
<div class="main-wrapper">
<header class='subheader'>
<div class='container'>
<h2 class='page-title'>
<small>
<a href='javascript:history.back()'>
<i class='fa fa-chevron-left'></i>
</a>
</small> {{ if .Title }}{{ .Title }}{{else}}{{.Site.Title}}{{end}}
</h2>
</div>
</header>
<main class="hfeed container" id="content" role="main">

View file

@ -0,0 +1,91 @@
{{ partial "top_list_projects.html" . }}
{{ $paginator := .Paginate (where .Data.Pages ".Section" "projects") }}
<div class='row'>
<div class='col-md-8'>
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
{{ range $index, $element := $paginator.Pages }}
<article class='post'>
<header class='post-header'>
<h2 class='post-title'>
<a href="{{ .Permalink }}">{{ .Title}}</a>
{{ if .Params.link }} &mdash; <a href="{{.Params.link}}">🔗</a>{{end}}
</h2>
<section class='post-meta text-muted'>
<ul class='list-inline'>
<li>
<i class='fa fa-calendar'></i>
<time class='post-date' datetime='{{ .Date.Format "2020-09-10" }}'> {{ .Date.Format "January 2 2006" }} </time>
</li>
<li>
<a class='fa-solid fa-book-open' href='{{ .Permalink }}'>
<span class='hidden'>Book icon</span>
</a>
<a href='{{ .Permalink }}'>Reading time: {{ .ReadingTime }} minute{{ if (ne .ReadingTime 1) }}s{{ end }}</a>
</li>
<li>
<i class='fa fa-folder-open'></i>
{{ $tags := .Params.tags}}
{{ with $tags }}
{{ range . }}
<a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a>
{{ end }}
{{ end }}
</li>
</ul>
</section>
</header>
<section class='post-content'>
{{.Content }}
</section>
</article>
{{ end }}
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
</div>
<div class='col-md-4'>
<nav class='menu tags'>
<span class="menu-heading">Project Categories</span>
<a class='menu-item selected' data-slug='all' href='/projects'>All posts</a>
{{ $hereSection := .Section }}
{{/* Get current taxonomy (example: tags, categories). */}}
<!-- {{ $taxonomy := .Data.Plural }} -->
{{ $taxonomy := "tags" }}
{{/* Get sections where one or more pages has one or more terms in the current taxonomy. */}}
{{ $sections := slice }}
{{ range $term, $weightedPages := index site.Taxonomies $taxonomy }}
{{ range $weightedPages }}
{{ $sections = $sections | append .CurrentSection | uniq | sort }}
{{ end }}
{{ end }}
{{/* List pages by term by section. */}}
{{ range $section := (where $sections ".Path" $hereSection) }}
{{ range $term, $weightedPages := index site.Taxonomies $taxonomy }}
{{ $termPage := site.GetPage path.Join $taxonomy $term }}
{{ with where $weightedPages "Section" $section.Path }}
<a href="{{ $termPage.RelPermalink }}" class='menu-item tag' data-slug='{{ $termPage.LinkTitle }}' title="All pages with tag <i>{{ $termPage.LinkTitle }}</i>">{{ $termPage.LinkTitle }}</a>
{{ end }}
{{ end }}
{{ end }}
</nav>
<br>
{{ partial "microblog.html" . }}
</div>
</div>
{{ partial "bottom_list.html" . }}

View file

@ -0,0 +1,67 @@
{{ partial "top_single.html" . }}
<div class='row'>
<div class='col-md-8'>
<article class='post'>
<header class='post-header'>
<section class='post-meta text-muted'>
<ul class='list-inline'>
<li>
<i class='fa fa-calendar'></i>
<time class='post-date' datetime='{{ .Date.Format "2020-09-10" }}'> {{ .Date.Format "January 2 2006" }} </time>
</li>
<li>
<a class='fa-solid fa-book-open' href='{{ .Permalink }}'>
<span class='hidden'>Book icon</span>
</a>
<a href='{{ .Permalink }}'>Reading time: {{ .ReadingTime }} minute{{ if (ne .ReadingTime 1) }}s{{ end }}</a>
</li>
<li>
<i class='fa fa-folder-open'></i>
{{ $tags := .Params.tags}}
{{ with $tags }}
{{ range . }}
<a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a>
{{ end }}
{{ end }}
</li>
</ul>
</section>
</header>
<section class='post-content'>
{{ .Content }}
{{ partial "article_footer.html" . }}
<section class='share text-center'>
<a class='share-twitter' href='https://twitter.com/intent/tweet?text={{ if .Title }}{{ .Title }}{{else}}{{.Site.Title}}{{end}}amp;url={{ .Permalink }}' onclick='window.open(this.href, &#39;twitter-share&#39;, &#39;width=550,height=235&#39;);return false;' true=''>
<span class='fa-stack fa-lg'>
<i class='fa fa-circle fa-stack-2x'></i>
<i class='fa-brands fa-twitter fa-stack-1x fa-inverse'></i>
</span>
</a>
<a class='share-facebook' href='https://www.facebook.com/sharer/sharer.php?u={{ .Permalink }}' onclick='window.open(this.href, &#39;facebook-share&#39;,&#39;width=580,height=296&#39;);return false;' true=''>
<span class='fa-stack fa-lg'>
<i class='fa fa-circle fa-stack-2x'></i>
<i class='fa-brands fa-facebook-f fa-stack-1x fa-inverse'></i>
</span>
</a>
</section>
{{ partial "comments.html" . }}
</section>
</article>
</div>
<div class='col-md-4'>
<nav class='menu tags'>
<span class="menu-heading">Recent Project Posts</span>
<a class='menu-item' data-slug='all' href='/projects'>All posts</a>
{{ $hereLink := .Permalink }}
{{ range (where .Site.Pages "Section" "projects") }}
{{ range .Pages }}
<a href='{{ .RelPermalink }}' class='menu-item tag{{ if (eq .Permalink $hereLink) }} selected{{ end }}' data-slug='{{ .Title}}' title="{{ .Title}}">{{ .Title}}</a>
{{ end }}
{{ end }}
</nav>
<br>
{{ partial "microblog.html" . }}
</div>
</div>
{{ partial "bottom_single.html" . }}

View file

@ -0,0 +1,91 @@
{{ partial "top_list_resource.html" . }}
{{ $paginator := .Paginate (where .Data.Pages ".Section" "resource") }}
<div class='row'>
<div class='col-md-8'>
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
{{ range $index, $element := $paginator.Pages }}
<article class='post'>
<header class='post-header'>
<h2 class='post-title'>
<a href="{{ .Permalink }}">{{ .Title}}</a>
{{ if .Params.link }} &mdash; <a href="{{.Params.link}}">🔗</a>{{end}}
</h2>
<section class='post-meta text-muted'>
<ul class='list-inline'>
<li>
<i class='fa fa-calendar'></i>
<time class='post-date' datetime='{{ .Date.Format "2020-09-10" }}'> {{ .Date.Format "January 2 2006" }} </time>
</li>
<li>
<a class='fa-solid fa-book-open' href='{{ .Permalink }}'>
<span class='hidden'>Book icon</span>
</a>
<a href='{{ .Permalink }}'>Reading time: {{ .ReadingTime }} minute{{ if (ne .ReadingTime 1) }}s{{ end }}</a>
</li>
<li>
<i class='fa fa-folder-open'></i>
{{ $tags := .Params.tags}}
{{ with $tags }}
{{ range . }}
<a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a>
{{ end }}
{{ end }}
</li>
</ul>
</section>
</header>
<section class='post-content'>
{{.Content }}
</section>
</article>
{{ end }}
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
</div>
<div class='col-md-4'>
<nav class='menu tags'>
<span class="menu-heading">Resource Categories</span>
<a class='menu-item selected' data-slug='all' href='/resource'>All posts</a>
{{ $hereSection := .Section }}
{{/* Get current taxonomy (example: tags, categories). */}}
<!-- {{ $taxonomy := .Data.Plural }} -->
{{ $taxonomy := "tags" }}
{{/* Get sections where one or more pages has one or more terms in the current taxonomy. */}}
{{ $sections := slice }}
{{ range $term, $weightedPages := index site.Taxonomies $taxonomy }}
{{ range $weightedPages }}
{{ $sections = $sections | append .CurrentSection | uniq | sort }}
{{ end }}
{{ end }}
{{/* List pages by term by section. */}}
{{ range $section := (where $sections ".Path" $hereSection) }}
{{ range $term, $weightedPages := index site.Taxonomies $taxonomy }}
{{ $termPage := site.GetPage path.Join $taxonomy $term }}
{{ with where $weightedPages "Section" $section.Path }}
<a href="{{ $termPage.RelPermalink }}" class='menu-item tag' data-slug='{{ $termPage.LinkTitle }}' title="All pages with tag <i>{{ $termPage.LinkTitle }}</i>">{{ $termPage.LinkTitle }}</a>
{{ end }}
{{ end }}
{{ end }}
</nav>
<br>
{{ partial "microblog.html" . }}
</div>
</div>
{{ partial "bottom_list.html" . }}

View file

@ -0,0 +1,68 @@
{{ partial "top_single.html" . }}
<div class='row'>
<div class='col-md-8'>
<article class='post'>
<header class='post-header'>
<section class='post-meta text-muted'>
<ul class='list-inline'>
<li>
<i class='fa fa-calendar'></i>
<time class='post-date' datetime='{{ .Date.Format "2020-09-10" }}'> {{ .Date.Format "January 2 2006" }} </time>
</li>
<li>
<a class='fa-solid fa-book-open' href='{{ .Permalink }}'>
<span class='hidden'>Book icon</span>
</a>
<a href='{{ .Permalink }}'>Reading time: {{ .ReadingTime }} minute{{ if (ne .ReadingTime 1) }}s{{ end }}</a>
</li>
<li>
<i class='fa fa-folder-open'></i>
{{ $tags := .Params.tags}}
{{ with $tags }}
{{ range . }}
<a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a>
{{ end }}
{{ end }}
</li>
</ul>
</section>
</header>
<section class='post-content'>
{{ if isset .Params "toc" }}{{ .TableOfContents }}{{ end}}
{{ .Content }}
{{ partial "backlinks.html" . }}
{{ partial "article_footer.html" . }}
<section class='share text-center'>
<a class='share-twitter' href='https://twitter.com/intent/tweet?text={{ if .Title }}{{ .Title }}{{else}}{{.Site.Title}}{{end}}amp;url={{ .Permalink }}' onclick='window.open(this.href, &#39;twitter-share&#39;, &#39;width=550,height=235&#39;);return false;' true=''>
<span class='fa-stack fa-lg'>
<i class='fa fa-circle fa-stack-2x'></i>
<i class='fa-brands fa-twitter fa-stack-1x fa-inverse'></i>
</span>
</a>
<a class='share-facebook' href='https://www.facebook.com/sharer/sharer.php?u={{ .Permalink }}' onclick='window.open(this.href, &#39;facebook-share&#39;,&#39;width=580,height=296&#39;);return false;' true=''>
<span class='fa-stack fa-lg'>
<i class='fa fa-circle fa-stack-2x'></i>
<i class='fa-brands fa-facebook-f fa-stack-1x fa-inverse'></i>
</span>
</a>
</section>
{{ partial "comments.html" . }}
</section>
</article>
</div>
<div class='col-md-4'>
<nav class='menu tags'>
<span class="menu-heading">Recent Resource Posts</span>
<a class='menu-item' data-slug='all' href='/resource'>All posts</a>
{{ $hereLink := .Permalink }}
{{ range (where .Site.Pages "Section" "resource") }}
{{ range .Pages }}
<a href='{{ .RelPermalink }}' class='menu-item tag{{ if (eq .Permalink $hereLink) }} selected{{ end }}' data-slug='{{ .Title}}' title="{{ .Title}}">{{ .Title}}</a>
{{ end }}
{{ end }}
</nav>
<br>
{{ partial "microblog.html" . }}
</div>
</div>
{{ partial "bottom_single.html" . }}

View file

@ -0,0 +1,4 @@
<div class="row">
{{ .Inner }}
</div>

View file

@ -0,0 +1,21 @@
<!--
0: Title
1: Link out
2: left bottom text
3: right bottom text
inner: description
-->
<div class="col">
<a class="card" href="{{ .Get 1 }}">
<div class="card__body">
<h3 style="margin:0;text-transform:capitalize">{{ .Get 0 }}</h3>
<small>{{ .Inner }}</small>
</div>
<hr style="margin:15px 0 10px 0">
<div style="display:flex;flex-direction:row;justify-content:space-between;padding:0 15px 10px 15px;font-size:0.8em">
<div>{{ .Get 2 }}</div>
<div>{{ .Get 3 }}</div>
</div>
</a>
</div>

View file

@ -0,0 +1,16 @@
<!--
0: Tttle
1: Description
inner: card-row
-->
<section class="section-lg">
<div class="container">
<div class="row responsiveCentered_2A7S">
<div class="col col--6 col--offset-3">
<h2 class="with-underline">{{ .Get 0 }}</h2>
<p>{{ .Get 1 }}</p>
</div>
</div>
{{ .Inner }}
</div>
</section>

View file

@ -0,0 +1,8 @@
<ul>
{{ $pages := .Site.RegularPages }}
{{ range (where $pages ".Draft" "!=" false) }}
<li><strong>{{dateFormat "Jan 2, 2006" .PublishDate}}</strong> - <a href="{{.Permalink}}">{{.Title}}</a> <br>
<blockquote>{{.Summary}}</blockquote>
</li>
{{ end }}
</ul>

View file

@ -0,0 +1,9 @@
<div class="code-with-header">
<div class="code-header">{{ .Get 0 }}</div>
<div class="scrollbar-container">
<div class="codeBlockWrapper_2QGZ">
{{ .Inner }}
<button type="button" aria-label="Copy code to clipboard" class="copyButton_1BYj">Copy</button>
</div>
</div>
</div>

View file

@ -0,0 +1,7 @@
<div class="yt-embed" style="position:relative;padding-bottom: 56.25%; padding-top: 25px; height: 0;">
<iframe
data-src="//www.youtube-nocookie.com/embed/{{ index .Params 0 }}?autoplay=1"
style="position: absolute; left: 0; top: 0; width: 100%; height: 100%; border:0;"
allowfullscreen
title="YouTube Video"></iframe>
</div>

View file

@ -0,0 +1,13 @@
<figure>
<img
{{ with .Get "src" }}src="{{ . }}"{{ end }}
{{ with .Get "alt" }}alt="{{ range (split . " ") }}{{ . }} {{ end }}"{{ end }}
{{ with .Get "attribution" }}attribution="{{ range (split . " ") }}{{ . }} {{ end }}"{{ end }}
{{ with .Get "srcset" }}srcset="{{ range (split . " ") }}{{ . }} {{ end }}"{{ end }}
{{ with .Get "tabindex" }}tabindex="{{ . }}"{{ end }}
{{ with .Get "role" }}role="{{ . }}"{{ end }}
{{ with .Get "width" }}width="{{ . }}"{{ end }}
{{ with .Get "height" }}height="{{ . }}"{{ end }}
{{ with .Get "layout" }}layout="{{ . }}"{{ end }} />
{{ with .Get "caption" }}<figcaption>{{ . }}</figcaption>{{ end }}
</figure>

View file

@ -0,0 +1 @@
{{.Inner}}

View file

@ -0,0 +1,8 @@
<section style="margin-top:100px;padding:50px 0;border-top:1px solid var(--custom-border-color)" class="hero is--dark">
<div class="container text--center">
<div>
<a class="button hero--button button--outline button--md button--secondary responsive-button button_2W20" style="margin:5px" href="{{ .Get 1 }}">{{ .Get 0 }}</a>
<a class="button hero--button button--md button--primary responsive-button button_2W20" to="https://git.qoto.org/goblin-ogm/goblin" style="margin:5px" href="{{ .Get 3 }}">{{ .Get 2 }}</a>
</div>
</div>
</section>

View file

@ -0,0 +1,56 @@
{{ partial "head.html" . }}
<body class="list">
<header class="me">
<div>
<img src="/images/logo.png" alt="A dashing picture of FLEAR.">
<h1>Hello.</h1>
<h2>I am FLEAR.</h2>
<h3>Lead and Manager for Chrome Developer Relations.</h3>
<p>I love the web. The web should allow anyone to access any experience that they need without the need for native install or content walled garden.</p>
</div>
</header>
<div>
<main>
{{ $paginator := .Paginate (where .Site.Pages "Type" "page") }}
{{ range $index, $element := $paginator.Pages }}
<div class="post">
<header class="{{ if .Params.image_header }}with-background{{end}}" style="{{ if .Params.image_header }}background-image: url('{{ .Params.image_header }}'); {{end}}">
<h3><a href="{{ .Permalink }}">{{ .Title}}</a></h3>
</header>
<div class="description">
<div class="author">
<div>
<strong>FLEAR</strong>
<span><time pubdate>{{ .Date.Format "January 2 2006" }}</time></span>
</div>
</div>
<p>{{ if .Description }}{{ .Description | html }}{{ else }}{{ .Summary }}{{ end }}</p>
<div style="text-align: right;">
<a class="read-more" href="{{ .Permalink }}">
Read More
</a>
</div>
</div>
</div>
{{ if eq $index 0 }}
{{end}}
{{ end }}
<nav style="text-align: center">
<div>
{{ if .Paginator.HasPrev }}
<a href="{{ .Paginator.Prev.URL }}">Previous</a>
{{ end }}
|
{{ if .Paginator.HasNext }}
<a href="{{ .Paginator.Next.URL }}">More</a>
{{ end }}
</div>
</nav>
</main>
</div>
</body>
</html>

View file

@ -0,0 +1,2 @@
<!-- raw html -->
{{.Inner}}

View file

@ -0,0 +1,3 @@
<section style="padding:50px 0" class="hero is--dark">
<div class="container text--center"></div>
</section>

View file

@ -0,0 +1,10 @@
<div class=slide>
<h4 id="#{{ index .Params 0 }}">{{ index .Params 1 }} <a href="#{{ index .Params 0 }}">#</a></h4>
<a href="{{ .Page.Params.slide_images }}{{ index .Params 0 }}.jpg">
<img src="{{ .Page.Params.slide_images }}t{{ index .Params 0 }}.jpg" alt="{{ index .Params 1 }}">
</a>
<p>{{ .Inner }}</p>
<br style="clear:both">
<hr>
</div>

View file

@ -0,0 +1,4 @@
<div class="codeBlockWrapper_2QGZ tab-pane" title="{{ .Get 0 }}">
{{ .Inner }}
<button type="button" aria-label="Copy code to clipboard" class="copyButton_1BYj">Copy</button>
</div>

View file

@ -0,0 +1,15 @@
<div class="ForDevelopers">
<div class="row code-tabs">
<div class="ButtonTabs col col--3">
<div class="nav nav-tabs"></div>
</div>
<div class="col col--9 code-with-header">
<div class="code-with-header">
<div class="code-header">{{ .Get 0 }}</div>
<div class="scrollbar-container tab-content">
{{ .Inner }}
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,17 @@
<header class="hero heroBanner_2Ftp">
<div class="container">
<div class="row">
<div class="col col--5">
<h2 class="hero__title">{{ .Get 0 }}</h2>
<p class="hero__subtitle">{{ .Get 1 }}</p>
<div>
<a class="button hero--button button--md button--secondary button--outline responsive-button button_2W20" style="margin-left:0;margin-top:10px" href="{{ .Get 3 }}">{{ .Get 2 }}</a>
<a class="button hero--button button--md button--primary responsive-button button_2W20" to="https://git.qoto.org/goblin-ogm/goblin" style="margin-top:10px" href="{{ .Get 5 }}">{{ .Get 4 }}</a>
</div>
</div>
<div class="col col--7">
{{ .Inner }}
</div>
</div>
</div>
</header>

View file

@ -0,0 +1,11 @@
<section class="forDevelopers_1bbC">
<div class="container">
<div class="row responsiveCentered_2A7S">
<div class="col col--6 col--offset-3">
<h2 class="with-underline">{{ .Get 0 }}</h2>
<p>{{ .Get 1 }}</p>
</div>
</div>
{{ .Inner }}
</div>
</section>

View file

@ -0,0 +1,49 @@
{{ partial "top_list_resource.html" . }}
{{ $paginator := .Paginate .Data.Pages }}
<div class='row'>
<div class='col-md-8'>
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
{{ range $index, $element := $paginator.Pages }}
<article class='post'>
<header class='post-header'>
<h2 class='post-title'>
<a href="{{ .Permalink }}">{{ .Title}}</a>
{{ if .Params.link }} &mdash; <a href="{{.Params.link}}">🔗</a>{{end}}
</h2>
</header>
</article>
{{ end }}
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
</div>
<div class='col-md-4'>
<nav class='menu tags'>
<span class="menu-heading">Categories</span>
{{range $name, $taxonomy := .Site.Taxonomies.tags}} {{ $cnt := .Count }}
{{ with $.Site.GetPage (printf "/tags/%s" $name) }}
<a href="{{ .RelPermalink }}" class='menu-item tag' data-slug='{{$name}}' title="All pages with tag <i>{{$name}}</i>">{{$name}} ({{$cnt}})</a>
{{end}}
{{end}}
</nav>
<br>
{{ partial "microblog.html" . }}
</div>
</div>
{{ partial "bottom_list.html" . }}

76
layouts/taxonomy/tag.html Normal file
View file

@ -0,0 +1,76 @@
{{ partial "top_list_resource.html" . }}
{{ $paginator := .Paginate .Data.Pages }}
<div class='row'>
<div class='col-md-8'>
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
{{ range $index, $element := $paginator.Pages }}
<article class='post'>
<header class='post-header'>
<h2 class='post-title'>
<a href="{{ .Permalink }}">{{ .Title}}</a>
{{ if .Params.link }} &mdash; <a href="{{.Params.link}}">🔗</a>{{end}}
</h2>
<section class='post-meta text-muted'>
<ul class='list-inline'>
<li>
<i class='fa fa-calendar'></i>
<time class='post-date' datetime='{{ .Date.Format "2020-09-10" }}'> {{ .Date.Format "January 2 2006" }} </time>
</li>
<li>
<a class='fa-solid fa-book-open' href='{{ .Permalink }}'>
<span class='hidden'>Book icon</span>
</a>
<a href='{{ .Permalink }}'>Reading time: {{ .ReadingTime }} minute{{ if (ne .ReadingTime 1) }}s{{ end }}</a>
</li>
<li>
<i class='fa fa-folder-open'></i>
{{ $tags := .Params.tags}}
{{ with $tags }}
{{ range . }}
<a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a>
{{ end }}
{{ end }}
</li>
</ul>
</section>
</header>
<section class='post-content'>
{{.Content }}
</section>
</article>
{{ end }}
<div class='extra-pagination inner'>
<nav class='pagination' role='pagination'>
{{ if .Paginator.HasNext }}<a href="{{ .Paginator.Next.URL }}" class="newer-posts">← Newer Posts</a>{{ end }}
<span class='page-number'>Page {{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}</span>
{{ if .Paginator.HasPrev }}<a href="{{ .Paginator.Prev.URL }}" class="older-posts">Older Posts →</a>{{ end }}
</nav>
</div>
</div>
<div class='col-md-4 side-menu'>
<nav class='menu tags'>
<span class="menu-heading">Categories</span>
{{ $hereLink := .Permalink }}
{{range $name, $taxonomy := .Site.Taxonomies.tags}} {{ $cnt := .Count }}
{{ with $.Site.GetPage (printf "/tags/%s" $name) }}
<a href="{{ .RelPermalink }}" class='menu-item tag{{ if (eq .Permalink $hereLink) }} selected{{ end }}' data-slug='{{$name}}' title="All pages with tag <i>{{$name}}</i>">{{$name}} ({{$cnt}})</a>
{{end}}
{{end}}
</nav>
<br>
{{ partial "microblog.html" . }}
</div>
</div>
{{ partial "bottom_list.html" . }}

View file

@ -0,0 +1,20 @@
import { AP } from "activitypub-core-types";
export async function fetchActorInformation(actorUrl: string): Promise<AP.Actor | null> {
try {
const response = await fetch(
actorUrl,
{
headers: {
"Content-type": 'application/activity+json',
"Accept": 'application/activity+json'
}
}
);
return await response.json();
} catch (error) {
console.log("Unable to fetch action information", actorUrl);
}
return null;
}

View file

@ -0,0 +1,7 @@
import { VercelRequest } from '@vercel/node';
import parser from '../../http-signature/index.js';
export function parseSignature(request: VercelRequest) {
const { url, method, headers } = request;
return parser.parse({ url, method, headers });
}

View file

@ -0,0 +1,44 @@
import { AP } from 'activitypub-core-types';
import { Sha256Signer } from '../../http-signature/index.js';
import { createHash } from 'crypto';
export async function sendSignedRequest(endpoint: URL, object: AP.Activity): Promise<Response> {
const publicKeyId = "https://flear.org/flear#main-key";
const privateKey = process.env.ACTIVITYPUB_PRIVATE_KEY;
const signer = new Sha256Signer({ publicKeyId, privateKey, headerNames: ["host", "date", "digest"] });
const requestHeaders = {
host: endpoint.hostname,
date: new Date().toUTCString(),
digest: `SHA-256=${createHash('sha256').update(JSON.stringify(object)).digest('base64')}`
};
// Generate the signature header
const signature = signer.sign({
url: endpoint,
method: "POST",
headers: requestHeaders
});
console.log("object", object);
console.log("endpoint", endpoint);
console.log("requestHeaders", requestHeaders);
console.log("signature", signature);
const response = await fetch(
endpoint,
{
method: 'POST',
body: JSON.stringify(object),
headers: {
'content-type': "application/activity+json",
accept: "application/activity+json",
...requestHeaders,
signature: signature
}
}
);
return response;
}

View file

@ -0,0 +1,21 @@
import { Parser, Sha256Signer, Sha256Signature, Signature } from '../../http-signature/index.js';
const publicKeyPem = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuPKVdZpt/olFWtyC4tpJ
bAoxyuFAQ7VDHNjDtC6ooirEtSzAnljaMcw3PLM0KiOTRUoZOeg2GRR4M8mBV2Lv
RdYYMnlFAJNXCTJ0uW3UxVFR5FrMeHlwA27zhwZpaQ74Daz8MTwZ+TFsJoA1UGJl
V3HKZ4QpVYrYNSKl6K75p3+EM/c8AlIA0BuFzJVZYsT3Q9ZJgJCDBXKhqBNEBRSt
6LwOONjzcQkzA9t6l6D0Hwzhb4ySspwH2Ur2X+QPisPnrc01KT3YTX/ygPDDnDcR
rSI+ehs+LtVPqPoAfYMzIKhlo4WLB5gmObO3oQRlg7kda0pFI9FGhLrwV0R3h0BT
kwIDAQAB
-----END PUBLIC KEY-----`;
const publicKeyId = "https://flear.org/flear#main-key";
const signature = new Sha256Signature({
publicKeyId,
string: 'host: qoto.org\ndate: Fri, 14 Jul 2023 20:36:24 GMT\ndigest: SHA-256=q0ubZcImm4TpDMjk6Q+geJZAsjgODR1m5V/pqcXFSdM=',
signature: Buffer.from("IiqUSA2oXSzNTsMJE7jOP7YDnL6K7Nol0rpDf+dnK8R14Y6FXX9VdSW43JTDvtnrmlf/lROR36pXXdf/IQhKtHkEJjKQUviBM5BhA9Qv5S84gGXyNkx2ytXEmxcL7BK2nuS/QoW9ud99ZcmhGkzWraoGQ7BM5UV63OCfA3EkKT0gP/QN76eMtuKVVQwCTNZtVxq/RBaJgExrn4+XwaWqFIBovVRM6p+3Pbg1T0e6Eo3Lsy6yn0Um7+iceyjVtveGiV60ywy9bkf85DzoFSSfB4y7sOhjartYyuuBY8HBRkheEywoJH1cK/q29F1Z6jByx84SrlD925sfTgOsB67DNw==")
})
console.log(signature.verify(publicKeyPem));

View file

@ -0,0 +1,14 @@
export function verifySignature(signature:any, publicKeyJson:any) {
let signatureValid;
try {
// Verify the signature
signatureValid = signature.verify(
publicKeyJson.publicKeyPem
);
} catch (error) {
console.log("Signature Verification error", error);
}
return signatureValid;
}

215
lib/http-signature/index.js Normal file
View file

@ -0,0 +1,215 @@
/** PK Fixed version.
* Activitypub HTTP Signatures
* Based on [HTTP Signatures draft 08](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-08)
* @module activitypub-http-signatures
*/
import crypto from 'crypto';
// token definitions from definitions in rfc7230 and rfc7235
const token = String.raw`[!#$%&'\*+\-\.\^_\`\|~0-9a-z]+`; // Key or value
const qdtext = String.raw`[^"\\\x7F]`; // Characters that don't need escaping
const quotedPair = String.raw`\\[\t \x21-\x7E\x80-\xFF]`; // Escaped characters
const quotedString = `(?:${qdtext}|${quotedPair})*`;
const fieldMatch = new RegExp(String.raw`(?<=^|,\s*)(${token})\s*=\s*(?:(${token})|"(${quotedString})")(?=,|$)`, 'ig');
const parseSigFields = str => Object.fromEntries(
Array.from(
str.matchAll(fieldMatch)
).map(
// capture groups: 1=fieldname, 2=field value if unquoted, 3=field value if quoted
v=>[
v[1],
v[2] ?? v[3].replace(
/\\./g,
c=>c[1]
)
]
)
);
const defaultHeaderNames = ['(request-target)', 'host', 'date'];
/**
* @private
* Generate the string to be signed for the signature header
* @param {Object} options Options
* @param {string} options.target The pathname of the request URL (including query and hash strings)
* @param {string} options.method The HTTP request method
* @param {object} options.headers Object whose keys are http header names and whose values are those headers' values
* @param {string[]} headerNames Names of the headers to use in the signature
* @returns {string}
*/
function getSignString({ target, method, headers }, headerNames) {
const requestTarget = `${method.toLowerCase()} ${target}`;
headers = {
...headers,
'(request-target)': requestTarget
};
return headerNames.map(header => `${header.toLowerCase()}: ${headers[header]}`).join('\n');
}
export class Sha256Signer {
#publicKeyId;
#privateKey;
#headerNames;
/**
* Class for signing a request and returning the signature header
* @param {object} options Config options
* @param {string} options.publicKeyId URI for public key that must be used for verification
* @param {string} options.privateKey Private key to use for signing
* @param {string[]} options.headerNames Names of headers to use in generating signature
*/
constructor({ publicKeyId, privateKey, headerNames }) {
this.#publicKeyId = publicKeyId;
this.#privateKey = privateKey;
this.#headerNames = headerNames ?? defaultHeaderNames;
}
/**
* Generate the signature header for an outgoing message
* @param {object} reqOptions Request options
* @param {string} reqOptions.url The full URL of the request to sign
* @param {string} reqOptions.method Method of the request
* @param {object} reqOptions.headers Dict of headers used in the request
* @returns {string} Value for the signature header
*/
sign({ url, method, headers }) {
const { host, pathname, search } = new URL(url);
const target = `${pathname}${search}`;
headers.date = headers.date || new Date().toUTCString();
headers.host = headers.host || host;
const headerNames = this.#headerNames;
const stringToSign = getSignString({ target, method, headers }, headerNames);
const signature = this.#signSha256(this.#privateKey, stringToSign).toString('base64');
return `keyId="${this.#publicKeyId}",headers="${headerNames.join(' ')}",signature="${signature.replace(/"/g, '\\"')}",algorithm="rsa-sha256"`;
}
/**
* @private
* Sign a string with a private key using sha256 alg
* @param {string} privateKey Private key
* @param {string} stringToSign String to sign
* @returns {Buffer} Signature buffer
*/
#signSha256(privateKey, stringToSign) {
const signer = crypto.createSign('sha256');
signer.update(stringToSign);
const signature = signer.sign(privateKey);
signer.end();
return signature;
}
}
/**
* Incoming request parser and Signature factory.
* If you wish to support more signature types you can extend this class
* and overide getSignatureClass.
*/
export class Parser {
/**
* Parse an incomming request's http signature header
* @param {object} reqOptions Request options
* @param {string} reqOptions.url The pathname (and query string) of the request URL
* @param {string} reqOptions.method Method of the request
* @param {object} reqOptions.headers Dict of headers used in the request
* @returns {Signature} Object representing the signature
* @throws {UnkownAlgorithmError} If the algorithm used isn't one we know how to verify
*/
parse({ headers, method, url }){
const fields = parseSigFields(headers.signature);
const headerNames = (fields.headers ?? 'date').split(/\s+/);
const signature = Buffer.from(fields.signature, 'base64');
const signString = getSignString({ target: url, method, headers }, headerNames);
const keyId = fields.keyId;
const algorithm = fields.algorithm ?? 'rsa-sha256';
return this.getSignatureClass(algorithm, { signature, string: signString, keyId });
}
/**
* Construct the signature class for a given algorithm.
* Override this method if you want to support additional
* algorithms.
* @param {string} algorithm The algorithm used by the signed request
* @param {object} options
* @param {Buffer} options.signature The signature as a buffer
* @param {string} options.string The string that was signed
* @param {string} options.keyId The ID of the public key to be used for verification
* @returns {Signature}
* @throws {UnkownAlgorithmError} If an unknown algorithm was used
*/
getSignatureClass(algorithm, { signature, string, keyId }) {
if(algorithm === 'rsa-sha256') {
return new Sha256Signature({ signature, string, keyId });
} else {
throw new UnkownAlgorithmError(`Don't know how to verify ${algorithm} signatures.`);
}
}
}
export class UnkownAlgorithmError extends Error {}
export class Signature {
#keyId;
constructor(keyId) {
this.#keyId = keyId;
}
get keyId(){
return this.#keyId;
}
verify(key){
return false;
}
}
export class Sha256Signature extends Signature {
#signature;
#string;
/**
* Class representing the HTTP signature
* @param {object} options
* @param {Buffer} options.signature The signature as a buffer
* @param {string} options.string The string that was signed
* @param {string} options.keyId The ID of the public key to be used for verification
*/
constructor({ signature, string, keyId }) {
super(keyId);
this.#signature = signature;
this.#string = string;
}
/**
* @property {string} keyId The ID of the public key that can verify the signature
*/
/**
* Verify the signature using a public key
* @param {string} key The public key matching the signature's keyId
* @returns {boolean}
*/
verify(key) {
const signature = this.#signature;
const signedString = this.#string;
const verifier = crypto.createVerify('sha256');
verifier.write(signedString);
verifier.end();
return verifier.verify(key, signature);
}
}
/**
* Default export: new instance of Parser class
*/
export default new Parser;

73
lib/whatwg-flora.ts Normal file
View file

@ -0,0 +1,73 @@
const encoder = new TextEncoder();
const pipeInto = async (from:any, controller:any) => {
const reader = from.getReader();
return reader.read().then(function process(result:any) {
if (result.done) {
return;
}
const value = result.value;
const isTypedArray = (value instanceof Int8Array
|| value instanceof Int16Array
|| value instanceof Int32Array
|| value instanceof Uint8Array
|| value instanceof Uint8ClampedArray
|| value instanceof Uint16Array
|| value instanceof Uint32Array
|| value instanceof Float32Array
|| value instanceof Float64Array)
if((isTypedArray === false && !!result.value) || (isTypedArray && value.length > 0)) {
controller.enqueue(result.value);
}
return reader.read().then(process);
});
};
const enqueueItem = async (val:any, controller:any) => {
if (val instanceof globalThis.ReadableStream) {
await pipeInto(val, controller);
}
else if (val instanceof Promise) {
let newVal;
newVal = await val;
if (newVal instanceof globalThis.ReadableStream) {
await pipeInto(newVal, controller);
} else {
await enqueueItem(newVal, controller);
}
}
else {
if (Array.isArray(val)) {
for (let item of val) {
await enqueueItem(item, controller)
}
}
else if (!!val) {
controller.enqueue(encoder.encode(val));
}
}
}
export default async (strings: TemplateStringsArray, ...values: any) => {
return new globalThis.ReadableStream({
start(controller) {
async function push() {
let i = 0;
while (i < values.length) {
let html = strings[i];
controller.enqueue(encoder.encode(html));
await enqueueItem(values[i], controller);
i++;
}
controller.enqueue(encoder.encode(strings[i]));
controller.close();
}
push();
}
});
};

12375
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "flear.org",
"description": "FLEAR (Free and Libre Engineers for Amateur Radio)",
"dependencies": {
"@firebase/app-types": "^0.9.0",
"@polymath-ai/client": "*",
"@polymath-ai/host": "*",
"@vercel/analytics": "^1.0.2",
"@vercel/edge": "^1.1.0",
"@vercel/node": "^3.0.7",
"@vercel/og": "^0.5.17",
"activitypub-core-types": "^0.3.2",
"ava": "^5.3.1",
"file-drop-element": "^1.0.1",
"firebase-admin": "^11.11.0",
"hugo-bin": "^0.115.0",
"multiparty": "^4.2.3",
"node-fetch": "^3.3.2",
"pinch-zoom-element": "^1.1.1",
"uuid": "^9.0.1",
"vercel": "^32.4.1",
"whatwg-flora-tmpl": "^1.0.3"
},
"engines": {
"node": ">= 18.0.0"
},
"devDependencies": {
"@types/multiparty": "^0.0.34",
"typescript": "^5.2.2"
},
"type": "module"
}

16
static/blank.html Normal file
View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- import the webpage's javascript file -->
<style>
html, body {
margin:0;
padding:0;
height: 100%;
}
</style>
</head>
</html>

BIN
static/favicon.png Normal file

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more