This commit is contained in:
Jill 2023-04-30 19:33:19 +03:00
commit 307e3a913a
Signed by: oat
GPG Key ID: 33489AA58A955108
42 changed files with 11039 additions and 0 deletions

13
.eslintignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

15
.eslintrc.cjs Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
root: true,
extends: ['eslint:recommended'],
plugins: ['svelte3'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: true
}
};

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
src/testPosts.json
/result
/todo.txt

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

61
flake.lock Normal file
View File

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1682669017,
"narHash": "sha256-Vi+p4y3wnl0/4gcwTdmCO398kKlDaUrNROtf3GOD2NY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7449971a3ecf857b4a554cf79b1d9dcc1a4647d8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-22.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

97
flake.nix Normal file
View File

@ -0,0 +1,97 @@
{
description = "cohost-blogger";
inputs = {
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.11";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
package = builtins.fromJSON (builtins.readFile ./package.json);
in
rec {
packages = flake-utils.lib.flattenTree rec {
cohost-blogger = pkgs.buildNpmPackage {
pname = "cohost-blogger";
inherit (package) version;
npmDepsHash = "sha256-ixRfoMWVKPomqZJuvKfE2dDrqq7DrTryMCFT37MY/c8=";
doCheck = true;
postCheck = ''
mkdir -p $out
mv build $out/
ln -s $out/lib/node_modules/cohost-blogger/package.json $out/build/package.json
ln -s $out/lib/node_modules/cohost-blogger/node_modules $out/build/node_modules
makeWrapper ${pkgs.nodejs-slim}/bin/node $out/bin/cohost-blogger \
--add-flags $out/build/index.js \
--chdir $out/lib/node_modules/cohost-blogger/
'';
src = ./.;
};
};
defaultPackage = packages.cohost-blogger;
nixosModule = { config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.cohost-blogger;
in {
options.services.cohost-blogger = {
enable = mkEnableOption "Enables the cohost-blogger server";
domain = mkOption {
type = types.nullOr types.str;
default = null;
description = "Which domain to host the server under; if disabled, NGINX is not used";
};
port = mkOption {
type = types.port;
default = 3500;
};
package = mkOption {
type = types.package;
default = self.packages.${system}.default;
};
};
config = mkIf cfg.enable {
systemd.services."cohost-blogger" = {
wantedBy = [ "multi-user.target" ];
environment = {
PORT = cfg.port;
};
serviceConfig = {
Restart = "on-failure";
ExecStart = "${getExe cfg.package}";
DynamicUser = "yes";
RuntimeDirectory = "cohost-blogger";
RuntimeDirectoryMode = "0755";
StateDirectory = "cohost-blogger";
StateDirectoryMode = "0700";
CacheDirectory = "cohost-blogger";
CacheDirectoryMode = "0750";
};
};
services.nginx = mkIf cfg.domain {
virtualHosts."${cfg.domain}" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:${cfg.port}/";
};
};
};
};
};
});
}

17
jsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

8521
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "cohost-blogger",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"lint": "eslint ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.2.4",
"@types/marked": "^4.0.8",
"@sveltejs/kit": "^1.5.0",
"eslint": "^8.28.0",
"eslint-plugin-svelte3": "^4.0.0",
"svelte": "^3.54.0",
"svelte-check": "^3.0.1",
"typescript": "^5.0.0",
"vite": "^4.2.0"
},
"type": "module",
"dependencies": {
"deepmerge": "^4.3.1",
"highlight.js": "^11.7.0",
"html-to-text": "^9.0.5",
"insane": "^2.6.2",
"marked": "^4.3.0",
"minify-xml": "^3.4.0",
"modern-normalize": "^1.1.0",
"reading-time": "^1.5.0",
"rehype-external-links": "^2.0.1",
"rehype-highlight": "^6.0.0",
"rehype-raw": "^6.1.1",
"rehype-sanitize": "^5.0.1",
"rehype-stringify": "^9.0.3",
"remark-gfm": "^3.0.1",
"remark-parse": "^10.0.1",
"remark-rehype": "^10.1.0",
"sass": "^1.62.0",
"stringify-entities": "^4.0.3",
"timeago.js": "^4.0.2",
"unified": "^10.1.2",
"unist-util-visit": "^4.1.2"
}
}

187
src/app.d.ts vendored Normal file
View File

@ -0,0 +1,187 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
type ASTMap = RootAST | ElementAST | TextAST
interface RootAST {
type: 'root',
position?: any,
children: ASTMap[]
}
interface ElementAST {
type: 'element',
position?: any,
children: ASTMap[],
properties: Record<string, any>,
tagName: string
}
interface TextAST {
type: 'text',
position?: any,
value: string
}
interface MarkdownStorageBlock {
type: 'markdown',
markdown: {
/** Raw markdown to be parsed at render-time. */
content: string,
},
}
type AttachmentId = string
interface AttachmentStorageBlock {
type: 'attachment',
attachment: {
/** ID for the [[`Attachment`]] to be rendered. */
attachmentId: AttachmentId,
altText: string?,
previewURL: string,
fileURL: string,
width: number?,
height: number?,
},
}
type StorageBlock = MarkdownStorageBlock | AttachmentStorageBlock
enum AccessResult {
Allowed = "allowed",
NotAllowed = "not-allowed",
LogInFirst = "log-in-first",
Blocked = "blocked",
}
enum LimitedVisibilityReason {
None = "none",
LogInFirst = "log-in-first",
}
type PostId = number
enum PostState {
Unpublished = 0,
Published,
Deleted,
}
interface Post {
postId: PostId,
headline: string,
publishedAt: string?,
filename: string,
transparentShareOfPostId: PostId?,
shareOfPostId: PostId?,
state: PostStateEnum,
numComments: number,
cws: string[],
tags: tags[],
hasCohostPlus: boolean,
pinned: boolean,
commentsLocked: boolean,
sharesLocked: boolean,
blocks: StorageBlock[], // TODO
plainTextBody: string,
postingProject: Project;
shareTree: unknown[]; // TODO
numSharedComments: number;
relatedProjects: Project[];
singlePostPageUrl: string;
effectiveAdultContent: boolean;
isEditor: boolean;
hasAnyContributorMuted: boolean;
contributorBlockIncomingOrOutgoing: boolean;
postEditUrl: string;
isLiked: boolean;
canShare: boolean;
canPublish: boolean;
limitedVisibilityReason: LimitedVisibilityReason;
astMap: {
initial: string,
initialLength: number,
expanded: string,
expandedLength: number,
};
}
type CommentId = string
interface Comment {
comment: {
commentId: CommentId,
postedAtISO: string,
deleted: boolean,
body: string,
children: Comment[],
postId: PostId,
inReplyTo: Comment?,
hasCohostPlus: boolean,
hidden: boolean
},
canInteract: AccessResult,
canEdit: AccessResult,
canHide: AccessResult,
poster: Project
}
type ProjectId = number
type ProjectHandle = string
enum ProjectPrivacy {
Public = "public",
Private = "private",
}
enum ProjectFlag {
Staff = "staff",
StaffMember = "staffMember",
FriendOfTheSite = "friendOfTheSite",
NoTransparentAvatar = "noTransparentAvatar",
Suspended = "suspended",
Automated = "automated", // used for the bot badge
Parody = "parody", // used for the "un-verified" badge
};
enum AvatarShape {
Circle = "circle",
RoundRect = "roundrect",
Squircle = "squircle",
CapsuleBig = "capsule-big",
CapsuleSmall = "capsule-small",
Egg = "egg",
}
enum LoggedOutPostVisibility {
Public = "public",
None = "none"
}
interface Project {
projectId: ProjectId,
handle: ProjectHandle,
displayName: string,
dek: string,
description: string,
avatarURL: string,
avatarPreviewURL: string,
headerURL: string?,
headerPreviewURL: string?,
privacy: ProjectPrivacyEnum,
url: string?,
pronouns: string?,
flags: ProjectFlag[],
avatarShape: AvatarShape,
loggedOutPostVisibility: LoggedOutPostVisibility,
frequentlyUsedTags: string[],
}
}
export {};

16
src/app.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="HandheldFriendly" content="True">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

105
src/app.scss Normal file
View File

@ -0,0 +1,105 @@
@use "sass:color";
$accent-color: #c177af;
:root {
line-height: 1.5;
font-size: 20px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
text-rendering: optimizeLegibility;
--text-color: #111;
--text-color-light: #444;
--background-color: #eee;
--card-overlay: rgba(255, 255, 255, 0.6);
--line-color: rgba(0, 0, 0, 0.2);
--accent-color: #{$accent-color};
}
@media (prefers-color-scheme: dark) {
body {
--text-color: #fff;
--text-color-light: #aaa;
--background-color: #111;
--card-overlay: rgba(25, 25, 25, 0.6);
--line-color: rgba(255, 255, 255, 0.2);
}
}
body {
color: var(--text-color);
background-color: var(--background-color);
accent-color: var(--accent-color);
}
a {
color: var(--accent-color);
}
::selection {
background-color: var(--accent-color);
color: #111;
}
blockquote, dl, dd, h1, h2, h3, h4, h5, h6, figure {
margin: 0;
}
hr {
color: var(--line-color);
}
img {
display: block;
}
pre, code {
font-family: monospace;
}
pre code {
white-space: pre-wrap;
width: 100%;
overflow: auto;
font-size: 0.8em !important;
}
blockquote {
font-weight: 500;
font-style: italic;
border-left: 0.25rem var(--line-color) solid;
quotes: "\201C""\201D""\2018""\2019";
margin-top: 1.6em;
margin-bottom: 1.6em;
padding-left: 1em;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.post {
isolation: isolate;
position: relative;
max-width: 800px;
word-break: break-word;
&.preview {
max-height: 40vh;
overflow: hidden;
* {
pointer-events: none;
}
}
figcaption {
color: var(--text-color-light) !important;
}
}
.link-wrapper, .link-wrapper:hover {
color: unset;
text-decoration: unset;
}

184
src/lib/CommandLine.svelte Normal file
View File

@ -0,0 +1,184 @@
<!-- svelte-ignore css-unused-selector -->
<style lang="scss">
@font-face {
font-family: ibm_vga;
src: url(/WebPlus_IBM_VGA_8x16.woff) format("woff");
src: url(/PxPlus_IBM_VGA_8x16.ttf) format("truetype");
font-weight: 400;
}
a {
color: #ee64ae;
text-shadow: 0 0 0.5px #ee64ae;
transition: 0.1s color, 0.1s text-shadow;
cursor: pointer;
}
a:hover {
color: #f075b7;
text-shadow: 0 0 2px #ee64ae;
}
b {
font-weight: normal;
text-shadow: 0 0 1px currentColor;
transition: 0.1s color, 0.1s text-shadow;
}
b {
color: #ffffff;
}
.black {
color: #46465c;
}
b.black {
color: #634560;
}
.red {
color: #e95678;
}
b.red {
color: #ec6a88;
}
.green {
color: #29d398;
}
b.green {
color: #3fdaa4;
}
.yellow {
color: #fab795;
}
b.yellow {
color: #fbc3a7;
}
.blue {
color: #26bbd9;
}
b.blue {
color: #3fc6de;
}
.magenta {
color: #ee64ae;
}
b.magenta {
color: #f075b7;
}
.cyan {
color: #59e3e3;
}
b.cyan {
color: #6be6e6;
}
.white {
color: #fdf0ed;
}
b.white {
color: #ffffff;
}
.black-bg {
background-color: #46465c;
}
.red-bg {
background-color: #e95678;
}
.green-bg {
background-color: #29d398;
}
.yellow-bg {
background-color: #fab795;
}
.blue-bg {
background-color: #26bbd9;
}
.magenta-bg {
background-color: #ee64ae;
}
.cyan-bg {
background-color: #59e3e3;
}
.white-bg {
background-color: #fdf0ed;
}
.bright-black-bg {
background-color: #634560;
}
.bright-red-bg {
background-color: #ec6a88;
}
.bright-green-bg {
background-color: #3fdaa4;
}
.bright-yellow-bg {
background-color: #fbc3a7;
}
.bright-blue-bg {
background-color: #3fc6de;
}
.bright-magenta-bg {
background-color: #f075b7;
}
.bright-cyan-bg {
background-color: #6be6e6;
}
.bright-white-bg {
background-color: #ffffff;
}
.red, .green, .yellow, .blue, .magenta, .cyan {
text-shadow: 0 0 0.5px currentColor;
}
.blinker {
animation: blinker 2s linear infinite;
}
@keyframes blinker {
0% {
opacity: 1;
}
49.9% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.command {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 8px;
}
.commandsymbol {
flex: 0 0 auto;
}
.silentlink {
color: inherit;
text-decoration: none;
text-shadow: none;
}
.commandline {
font-size: 16px;
font-family: NotoEmoji-Regular, ibm_vga, monospace;
background-color: #17101a;
color: #e6dedc;
padding: 2em;
}
</style>
<div class="command commandline">
<span>
<a href="/" class="silentlink">blog.</a><a href="https://oat.zone/" class="silentlink">oat.zone</a>
</span>
<div class="green commandsymbol">λ</div>
<span><slot/></span>
<span class="blinker"></span>
</div>

150
src/lib/Comment.svelte Normal file
View File

@ -0,0 +1,150 @@
<script>
import { onMount } from "svelte";
import { renderCommentMarkdown } from "./markdown/rendering";
import * as timeago from 'timeago.js';
import ProfilePicture from "./ProfilePicture.svelte";
/**
* @type {Comment}
*/
export let data;
let timestampElement;
onMount(() => {
timeago.render([timestampElement]);
});
</script>
<style lang="scss">
.comment {
position: relative;
display: flex;
flex-direction: row;
gap: 1rem;
.pointer {
position: absolute;
top: -16px;
}
.content {
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 0.5rem;
.author {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
.display-name {
display: block;
max-width: 100%;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.handle {
display: block;
color: var(--text-color-light);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
a.timestamp {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
time {
display: block;
flex: 0 0 none;
font-size: small;
color: var(--text-color-light);
}
}
.comment-text {
margin-top: 0;
margin-bottom: 0;
overflow-wrap: break-word;
:global(p) {
margin: 0;
}
:global(img) {
margin-top: 2em;
margin-bottom: 2em;
}
}
}
}
.thread {
padding-left: 1rem;
margin-left: 2rem;
border-left: 0.25rem solid var(--line-color);
display: flex;
gap: 1rem;
flex-direction: column;
}
</style>
<article class="comment">
<a id="comment-{data.comment.commentId}" class="pointer"></a>
<ProfilePicture url={data.poster.avatarURL} type={data.poster.avatarShape} handle={data.poster.handle} hideOnMobile={true}/>
<div class="content">
<div class="author">
<ProfilePicture url={data.poster.avatarURL} type={data.poster.avatarShape} handle={data.poster.handle} hideOnMobile={false} tiny/>
<a
rel="author"
href="https://cohost.org/{data.poster.handle}"
class="display-name"
title="{data.poster.displayName}">
{data.poster.displayName}
</a>
<a href="https://cohost.org/{data.poster.handle}" class="handle">
@{data.poster.handle}
</a>
<a href="#comment-{data.comment.commentId}" class="timestamp">
<time
bind:this={timestampElement}
datetime="{data.comment.postedAtISO}"
title="{(new Date(data.comment.postedAtISO)).toString()}"
>
{timeago.format(data.comment.postedAtISO)}
</time>
</a>
</div>
<div class="comment-text">
{@html renderCommentMarkdown(data.comment.body)}
</div>
</div>
</article>
{#each data.comment.children as child}
<div class="thread">
<svelte:self data={child}/>
</div>
{/each}

View File

@ -0,0 +1,132 @@
<script>
/**
* @type {AvatarShape}
*/
export let type;
/**
* @type {string}
*/
export let url;
/**
* @type {string}
*/
export let handle;
/**
* @type {boolean?}
*/
export let hideOnMobile;
/**
* @type {boolean?}
*/
export let tiny;
/**
* @param {AvatarShape} avatarShape
*/
function avatarMaskClass(avatarShape) {
switch (avatarShape) {
case 'capsule-big':
return 'mask-capsule-big';
case 'capsule-small':
return 'mask-capsule-small';
case 'roundrect':
return 'mask-roundrect';
case 'squircle':
return 'mask-squircle';
case 'egg':
return 'mask-egg';
default:
case 'circle':
return 'mask-circle';
}
}
const avatarURL = `${url}?width=80&height=80&fit=cover&dpr=2&auto=webp`;
const noTransparentAvatar = false;
const maskName = avatarMaskClass(type);
</script>
<style lang="scss">
.profile-picture {
display: block;
flex: 0 0 auto;
position: relative;
aspect-ratio: 1 / 1;
width: 4rem;
height: 4rem;
&.tiny {
width: 2rem;
height: 2rem;
}
&.hide-on-mobile {
display: none;
@media (min-width: 1024px) {
display: block;
}
}
&.show-on-mobile {
display: block;
@media (min-width: 1024px) {
display: none;
}
}
img {
width: 100%;
height: 100%;
object-fit: cover;
&.no-transparent-avatar {
background-image: var(--avatar);
}
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
&.mask-squircle {
mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTAwIDBDMjAgMCAwIDIwIDAgMTAwczIwIDEwMCAxMDAgMTAwIDEwMC0yMCAxMDAtMTAwUzE4MCAwIDEwMCAweiIvPjwvc3ZnPg==);
}
&.mask-roundrect {
//@apply rounded-lg;
border-radius: 0.5rem;
}
&.mask-circle {
//@apply rounded-full;
border-radius: 9999px;
}
&.mask-egg {
mask-image: url('/masks/egg.svg');
}
&.mask-capsule-big {
mask-image: url('/masks/capsule-big.svg');
}
&.mask-capsule-small {
mask-image: url('/masks/capsule-small.svg');
}
}
}
</style>
<a
href="https://cohost.org/{handle}"
class="profile-picture"
class:hide-on-mobile={hideOnMobile === true}
class:show-on-mobile={hideOnMobile === false}
class:tiny={tiny}
title="@{handle}"
>
<img
class={maskName}
class:no-transparent-avatar={noTransparentAvatar}
style="--avatar:url('{avatarURL}')"
src={avatarURL}
alt={handle}
/>
</a>

146
src/lib/cohost.js Normal file
View File

@ -0,0 +1,146 @@
import { stringifyEntities } from 'stringify-entities';
import * as fs from 'fs/promises';
import config from '$lib/config';
/**
* @param {Record<string, any>} properties
*/
function renderProperties(properties) {
if (Object.keys(properties).length === 0) return '';
return ' ' + Object.entries(properties)
.filter(([k, v]) => k !== '' && v !== '')
.map(([k, v]) =>
v ? `${k}=${typeof v === 'string' ? `"${stringifyEntities(v)}"` : v.toString()}` : k
).join(' ');
}
/**
* @param {ASTMap} ast
* @returns {string}
*/
// todo: obliterate from orbit
export function renderASTMap(ast) {
switch (ast.type) {
case 'root':
return ast.children.map(c => renderASTMap(c)).join('')
case 'element':
if (ast.tagName === 'a') {
ast.properties.target = '_blank';
ast.properties.rel = 'noreferrer noopener';
}
if (ast.properties.id && ast.properties.id.includes('cohost-blogger-ignore')) return '';
return `<${ast.tagName}${renderProperties(ast.properties)}>${ast.children.map(c => renderASTMap(c)).join('')}</${ast.tagName}>`;
case 'text':
return stringifyEntities(ast.value);
default:
return '';
}
}
const COHOST_API_URI = 'https://cohost.org/api/v1/trpc/';
/**
* @param {string} route
* @param {Record<string, any>} input
*/
export async function trpcRequest(route, input) {
const url = new URL(COHOST_API_URI + route);
if (input) url.searchParams.set('input', JSON.stringify(input));
const data = await (await fetch(url)).json();
return data;
}
const PAGES_PER_POST = 20;
/**
* @param {number} page
* @returns {Promise<Post[]>}
*/
export async function fetchAllPosts(page = 0) {
const data = await trpcRequest('posts.getPostsTagged', {
projectHandle: config.handle,
tagSlug: config.tag,
page: page
});
let posts = data.result.data.items;
if (data.result.data.nItems >= PAGES_PER_POST) {
posts = [...posts, ...(await fetchAllPosts(page + 1))]
}
return posts;
}
/**
* @returns {Promise<Post[]>}
*/
async function getPostsUncached() {
return await fetchAllPosts();
//return JSON.parse(await fs.readFile('src/testPosts.json', 'utf8')).filter(post => post.tags.includes('cohost-blogger'));
}
// this technically only stores the preview data - the posts on the actual pages are always fetched
// however there is no way to fetch a specified amount of info, so cache it is
let postCache = {
/** @type {Post[]} **/
posts: [],
refreshed: -1
}
const CACHE_INVALID_PERIOD = 60 * 1000;
/**
* @returns {Promise<Post[]>}
*/
export async function getPosts() {
const timeSinceCache = Date.now() - postCache.refreshed;
if (timeSinceCache > CACHE_INVALID_PERIOD) {
postCache.posts = await getPostsUncached();
postCache.refreshed = Date.now();
}
return postCache.posts;
}
/**
* @param {Post} post
*/
export function getPostImages(post) {
return post.blocks.filter(block => block.type === 'attachment').map(block => block.attachment);
}
const COMMENT_REGEX = /^\s*<!--\s*@cohost-blogger-meta\s+([\S\s]+)\s*-->\s*$/;
/**
* @param {Post} post
* @returns {Record<string, string>}
*/
export function getPostMetadata(post) {
return post.blocks
.filter(block => block.type === 'markdown')
.map(block => block.markdown.content)
.map(text => COMMENT_REGEX.exec(text)).filter(res => res !== null).map(res => res[1])
.reduce((lines, comment) => [...lines, ...comment.split('\n')], [])
.map(line => line.trim())
.filter(line => line.length > 0)
.reduce((properties, line) => {
properties[line.split(':')[0].trim()] = line.split(':')[1].trim();
return properties;
}, {});
}
/**
* @param {Post} post
* @returns {Date | null}
*/
export function getPostPublishDate(post) {
const meta = getPostMetadata(post);
if (meta['published-at']) return new Date(meta['published-at']);
if (post.publishedAt) return new Date(post.publishedAt);
return null;
}
/**
* @param {Post} post
* @returns {string}
*/
export function getPostSlug(post) {
return getPostMetadata(post).slug || post.filename;
}

7
src/lib/config.js Normal file
View File

@ -0,0 +1,7 @@
export default {
handle: 'oatmealine',
tag: 'oatposting',
blogName: 'breakfast oatmeal',
blogDescription: 'cat /dev/urandom as a service - jill\'s tiny little blog place',
siteURL: 'https://blog.oat.zone'
};

4
src/lib/constants.js Normal file
View File

@ -0,0 +1,4 @@
export default {
name: 'cohost-blogger',
repo: 'https://git.oat.zone/oat/cohost-blogger'
}

View File

@ -0,0 +1,113 @@
import { visit } from "unist-util-visit";
/**
* @param {RootAST} hast
*/
export function copyImgAltToTitle(hast) {
visit(hast, { type: 'element', tagName: 'img' }, (node) => {
if (node.properties?.alt) {
node.properties.title = node.properties.alt;
}
});
}
/**
* @param {RootAST} hast
*/
export function lazyLoadImages(hast) {
visit(hast, { type: 'element', tagName: 'img' }, (node) => {
node.properties.loading = 'lazy';
});
}
/**
* @param {RootAST} hast
*/
export function dropCohostBloggerIgnoreBlocks(hast) {
visit(hast, { type: 'element' }, (node, index, parent) => {
if (parent === null || index === null) return;
// remove any elements that match the class
if (node.properties.className && node.properties.className.includes('cohost-blogger-ignore')) {
parent.children.splice(index, 1);
// if there's nothing else in the parent, then remove it aswell
if (parent.children.length === 0 && parent.type === 'element') {
// don't actually know how to remove it lol... display: none will do
parent.properties.style = 'display:none';
}
}
return true;
});
}
// largely copied from makeIframelyEmbeds
/**
* @param {RootAST} hast
*/
export function makeLazyEmbeds(hast) {
visit(hast, { type: 'element', tagName: 'a' }, (node, index, parent) => {
if (parent === null || index === null) return;
// GFM autolink literals have the following two properties:
// - they have exactly one child, and it's a text child;
if (node.children.length != 1 || node.children[0].type != 'text')
return;
// - the starting offset of the text child matches the starting offset
// of the node (angle-bracket autolinks and explicit links differ by 1
// char)
if (
!node.position ||
!node.children[0].position ||
node.children[0].position.start.offset != node.position.start.offset
)
return;
// additionally, GFM autolink literals in their own paragraph are the
// only child of their parent node.
if (parent.children.length != 1) return;
// plain videos
// todo: THIS IS LAZY!!!!
if (node.properties?.href.endsWith('.mp4') || node.properties?.href.endsWith('.webm')) {
// render the parent element to fit the video better
if (parent.type === 'element') {
parent.tagName = 'div';
parent.properties.style = 'width:100%;display:flex;justify-content:center'
}
parent.children.splice(index, 1, {
type: 'element',
tagName: 'video',
properties: {
src: node.properties?.href,
autoplay: 'true',
playsinline: 'true',
loop: 'true',
style: 'width: 100%;max-width: 600px',
controls: 'true'
},
children: [],
});
// youtube videos
} else if (node.properties?.href.startsWith('https://www.youtube.com/')) {
// <iframe src="https://www.youtube.com/embed/avNF21NRe10?feature=oembed" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen="" title="[NotITG Release] eyes in the water" name="fitvid0" frameborder="0"></iframe>
parent.children.splice(index, 1, {
type: 'element',
tagName: 'iframe',
properties: {
src: node.properties?.href.replace('/watch?v=', '/embed/'),
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
frameborder: 0,
style: 'width:100%;aspect-ratio:16/9',
allowfullscreen: 'true'
},
children: [],
});
}
return true;
});
}

View File

@ -0,0 +1,136 @@
// most of this is borrowed from cohost's source code - i have no clue how it all works lmfao
import { unified } from 'unified';
import rehypeExternalLinks from 'rehype-external-links';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeHighlight from 'rehype-highlight'
import rehypeStringify from 'rehype-stringify';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import glsl from 'highlight.js/lib/languages/glsl'
import deepmerge from 'deepmerge';
import { compile } from 'html-to-text';
import { copyImgAltToTitle, dropCohostBloggerIgnoreBlocks, lazyLoadImages, makeLazyEmbeds } from './processors';
const convert = compile({
wordwrap: false,
});
const THIRD_AGE_SCHEMA = deepmerge(defaultSchema, {
attributes: {
"*": ["style"],
},
tagNames: ["video", "audio", "aside"], // consistency with current rules,
});
const externalRel = ['nofollow', 'noopener', 'noreferrer'];
/**
* @param {string} src
* @param {boolean} [xhtml]
*/
export function renderPostMarkdown(src, xhtml) {
return unified()
.use(remarkParse)
.use(remarkGfm, {
singleTilde: false,
})
.use(remarkRehype, {
allowDangerousHtml: true,
})
.use(rehypeHighlight, {
ignoreMissing: true,
languages: {glsl},
})
.use(() => copyImgAltToTitle)
.use(() => lazyLoadImages)
//.use(() => cleanUpFootnotes)
.use(rehypeRaw)
.use(() => dropCohostBloggerIgnoreBlocks)
// sanitization on trusted posts isn't suuuper necessary
// and prevents things like classes from being passed
// along
//.use(rehypeSanitize, COHOST_BLOGGER_SCHEMA)
//.use(() => stripSecondAgeStyles(postDate))
//.use(() => stripThirdAgeStyles(postDate));
.use(() => makeLazyEmbeds)
.use(rehypeExternalLinks, {
rel: externalRel,
target: '_blank',
})
.use(rehypeStringify, {
closeSelfClosing: xhtml,
upperDoctype: xhtml
})
//.use(() => convertMentions)
//.use(parseEmoji, { cohostPlus: options.hasCohostPlus })
.processSync(src)
.toString();
}
/**
* @param {StorageBlock[]} blocks
*/
export function renderPostSummaryMarkdown(blocks) {
const origBlocks = blocks.filter(block => block.type === 'markdown');
const readmoreIndex = origBlocks.findIndex(
(block) => block.markdown.content === "---"
);
if (readmoreIndex > -1) {
origBlocks.splice(readmoreIndex);
}
return renderPostMarkdown(origBlocks.map(b => b.markdown.content).join('\n\n'));
}
/**
* @param {string} src
* @returns string
*/
export function renderCommentMarkdown(src) {
return unified()
.use(remarkParse)
.use(remarkGfm, {
singleTilde: false,
})
.use(remarkRehype)
.use(() => copyImgAltToTitle)
.use(() => lazyLoadImages)
//.use(() => cleanUpFootnotes)
.use(rehypeSanitize, THIRD_AGE_SCHEMA)
//.use(() => stripSecondAgeStyles(postDate))
.use(rehypeExternalLinks, {
rel: externalRel,
target: '_blank',
})
.use(rehypeStringify)
//.use(() => convertMentions)
//.use(parseEmoji, { cohostPlus: options.hasCohostPlus })
.processSync(src)
.toString();
}
/**
* @param {string} src
* @returns string
*/
export function renderPlaintext(src) {
const renderedBody = renderCommentMarkdown(src);
return convert(renderedBody);
}
/**
* @param {string} src
* @returns string
*/
export function renderPostPlaintext(src) {
const renderedBody = renderPostMarkdown(src);
return convert(renderedBody);
}
/**
* @param {StorageBlock[]} blocks
* @returns string
*/
export function renderPostSummaryPlaintext(blocks) {
const renderedBody = renderPostSummaryMarkdown(blocks);
return convert(renderedBody);
}

6
src/lib/utils.js Normal file
View File

@ -0,0 +1,6 @@
/**
* @param {Date} date
*/
export function formatDate(date) {
return `${['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
}

4
src/params/slug.js Normal file
View File

@ -0,0 +1,4 @@
/** @type {import('@sveltejs/kit').ParamMatcher} */
export function match(param) {
return /^[a-z0-9-]+$/.test(param);
}

450
src/prose.scss Normal file
View File

@ -0,0 +1,450 @@
.prose {
color: var(--tw-prose-body);
}
.prose :where(p) {
margin-top: 1.25em;
margin-bottom: 1.25em;
}
.prose :where([class~="lead"]) {
color: var(--tw-prose-lead);
font-size: 1.25em;
line-height: 1.6;
margin-top: 1.2em;
margin-bottom: 1.2em;
}
.prose :where(strong) {
color<