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: var(--tw-prose-bold);
font-weight: 600;
}
.prose :where(a strong) {
color: inherit;
}
.prose :where(blockquote strong) {
color: inherit;
}
.prose :where(thead th strong) {
color: inherit;
}
.prose :where(ol) {
list-style-type: decimal;
margin-top: 1.25em;
margin-bottom: 1.25em;
padding-left: 1.625em;
}
.prose :where(ol[type="A"]) {
list-style-type: upper-alpha;
}
.prose :where(ol[type="a"]) {
list-style-type: lower-alpha;
}
.prose :where(ol[type="A" s]) {
list-style-type: upper-alpha;
}
.prose :where(ol[type="a" s]) {
list-style-type: lower-alpha;
}
.prose :where(ol[type="I"]) {
list-style-type: upper-roman;
}
.prose :where(ol[type="i"]) {
list-style-type: lower-roman;
}
.prose :where(ol[type="I" s]) {
list-style-type: upper-roman;
}
.prose :where(ol[type="i" s]) {
list-style-type: lower-roman;
}
.prose :where(ol[type="1"]) {
list-style-type: decimal;
}
.prose :where(ul) {
list-style-type: disc;
margin-top: 1.25em;
margin-bottom: 1.25em;
padding-left: 1.625em;
}
.prose :where(ol > li) ::marker{
font-weight: 400;
color: var(--tw-prose-counters);
}
.prose :where(ul > li) ::marker{
color: var(--tw-prose-bullets);
}
.prose :where(hr) {
border-color: var(--tw-prose-hr);
border-top-width: 1px;
margin-top: 3em;
margin-bottom: 3em;
}
.prose :where(blockquote) {
font-weight: 500;
font-style: italic;
color: var(--tw-prose-quotes);
border-left-width: 0.25rem;
border-left-color: var(--tw-prose-quote-borders);
quotes: "\201C""\201D""\2018""\2019";
margin-top: 1.6em;
margin-bottom: 1.6em;
padding-left: 1em;
}
.prose :where(blockquote p:first-of-type)::before {
content: open-quote;
}
.prose :where(blockquote p:last-of-type)::after {
content: close-quote;
}
.prose :where(h1) {
color: var(--tw-prose-headings);
font-weight: 800;
font-size: 2.25em;
margin-top: 0;
margin-bottom: 0.8888889em;
line-height: 1.1111111;
}
.prose :where(h1 strong) {
font-weight: 900;
color: inherit;
}
.prose :where(h2) {
color: var(--tw-prose-headings);
font-weight: 700;
font-size: 1.5em;
margin-top: 2em;
margin-bottom: 1em;
line-height: 1.3333333;
}
.prose :where(h2 strong) {
font-weight: 800;
color: inherit;
}
.prose :where(h3) {
color: var(--tw-prose-headings);
font-weight: 600;
font-size: 1.25em;
margin-top: 1.6em;
margin-bottom: 0.6em;
line-height: 1.6;
}
.prose :where(h3 strong) {
font-weight: 700;
color: inherit;
}
.prose :where(h4) {
color: var(--tw-prose-headings);
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.5em;
line-height: 1.5;
}
.prose :where(h4 strong) {
font-weight: 700;
color: inherit;
}
.prose :where(img) {
margin-top: 2em;
margin-bottom: 2em;
}
.prose :where(figure > *) {
margin-top: 0;
margin-bottom: 0;
}
.prose :where(figcaption) {
color: var(--tw-prose-captions);
font-size: 0.875em;
line-height: 1.4285714;
margin-top: 0.8571429em;
}
.prose :where(code) {
color: var(--tw-prose-code);
font-weight: 600;
font-size: 0.875em;
}
.prose :where(code)::before {
content: "`";
}
.prose :where(code)::after {
content: "`";
}
.prose :where(a code) {
color: inherit;
}
.prose :where(h1 code) {
color: inherit;
}
.prose :where(h2 code) {
color: inherit;
font-size: 0.875em;
}
.prose :where(h3 code) {
color: inherit;
font-size: 0.9em;
}
.prose :where(h4 code) {
color: inherit;
}
.prose :where(blockquote code) {
color: inherit;
}
.prose :where(thead th code) {
color: inherit;
}
.prose :where(pre code) {
background-color: transparent;
border-width: 0;
border-radius: 0;
padding: 0;
font-weight: inherit;
color: inherit;
font-size: inherit;
font-family: inherit;
line-height: inherit;
}
.prose :where(pre code)::before {
content: none;
}
.prose :where(pre code)::after {
content: none;
}
.prose :where(table) {
width: 100%;
table-layout: auto;
text-align: left;
margin-top: 2em;
margin-bottom: 2em;
font-size: 0.875em;
line-height: 1.7142857;
}
.prose :where(thead) {
border-bottom-width: 1px;
border-bottom-color: var(--tw-prose-th-borders);
}
.prose :where(thead th) {
color: var(--tw-prose-headings);
font-weight: 600;
vertical-align: bottom;
padding-right: 0.5714286em;
padding-bottom: 0.5714286em;
padding-left: 0.5714286em;
}
.prose :where(tbody tr) {
border-bottom-width: 1px;
border-bottom-color: var(--tw-prose-td-borders);
}
.prose :where(tbody tr:last-child) {
border-bottom-width: 0;
}
.prose :where(tbody td) {
vertical-align: baseline;
}
.prose :where(tfoot) {
border-top-width: 1px;
border-top-color: var(--tw-prose-th-borders);
}
.prose :where(tfoot td) {
vertical-align: top;
}
.prose{
--tw-prose-body: #374151;
--tw-prose-headings: #111827;
--tw-prose-lead: #4b5563;
--tw-prose-links: #111827;
--tw-prose-bold: #111827;
--tw-prose-counters: #6b7280;
--tw-prose-bullets: #d1d5db;
--tw-prose-hr: #e5e7eb;
--tw-prose-quotes: #111827;
--tw-prose-quote-borders: #e5e7eb;
--tw-prose-captions: #6b7280;
--tw-prose-code: #111827;
--tw-prose-pre-code: #e5e7eb;
--tw-prose-pre-bg: #1f2937;
--tw-prose-th-borders: #d1d5db;
--tw-prose-td-borders: #e5e7eb;
--tw-prose-invert-body: #d1d5db;
--tw-prose-invert-headings: #fff;
--tw-prose-invert-lead: #9ca3af;
--tw-prose-invert-links: #fff;
--tw-prose-invert-bold: #fff;
--tw-prose-invert-counters: #9ca3af;
--tw-prose-invert-bullets: #4b5563;
--tw-prose-invert-hr: #374151;
--tw-prose-invert-quotes: #f3f4f6;
--tw-prose-invert-quote-borders: #374151;
--tw-prose-invert-captions: #9ca3af;
--tw-prose-invert-code: #fff;
--tw-prose-invert-pre-code: #d1d5db;
--tw-prose-invert-pre-bg: rgb(0 0 0 / 50%);
--tw-prose-invert-th-borders: #4b5563;
--tw-prose-invert-td-borders: #374151;
font-size: 1rem;
line-height: 1.75;
}
.prose :where(video) {
margin-top: 2em;
margin-bottom: 2em;
}
.prose :where(figure) {
margin-top: 2em;
margin-bottom: 2em;
}
.prose :where(li) {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.prose :where(ol > li) {
padding-left: 0.375em;
}
.prose :where(ul > li) {
padding-left: 0.375em;
}
.prose :where(.prose > ul > li p) {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.prose :where(.prose > ul > li > *:first-child) {
margin-top: 1.25em;
}
.prose :where(.prose > ul > li > *:last-child) {
margin-bottom: 1.25em;
}
.prose :where(.prose > ol > li > *:first-child) {
margin-top: 1.25em;
}
.prose :where(.prose > ol > li > *:last-child) {
margin-bottom: 1.25em;
}
.prose :where(ul ul, ul ol, ol ul, ol ol) {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.prose :where(hr + *) {
margin-top: 0;
}
.prose :where(h2 + *) {
margin-top: 0;
}
.prose :where(h3 + *) {
margin-top: 0;
}
.prose :where(h4 + *) {
margin-top: 0;
}
.prose :where(thead th:first-child) {
padding-left: 0;
}
.prose :where(thead th:last-child) {
padding-right: 0;
}
.prose :where(tbody td, tfoot td) {
padding-top: 0.5714286em;
padding-right: 0.5714286em;
padding-bottom: 0.5714286em;
padding-left: 0.5714286em;
}
.prose :where(tbody td:first-child, tfoot td:first-child) {
padding-left: 0;
}
.prose :where(tbody td:last-child, tfoot td:last-child) {
padding-right: 0;
}
.prose :where(.prose > :first-child) {
margin-top: 0;
}
.prose :where(.prose > :last-child) {
margin-bottom: 0;
}
@media (prefers-color-scheme: dark){
.prose{
color: #f9fafb;
}
.prose :where([class~="lead"]) {
color: #9ca3af;
}
.prose :where(figcaption) {
color: #9ca3af;
}
.prose :where(strong) {
color: #f3f4f6;
}
.prose :where(ul > li)::before {
background-color: #374151;
}
.prose :where(hr) {
border-color: #1f2937;
}
.prose :where(blockquote) {
color: #f3f4f6;
border-left-color: #1f2937;
}
.prose :where(h1) {
color: #f3f4f6;
}
.prose :where(h2) {
color: #f3f4f6;
}
.prose :where(h3) {
color: #f3f4f6;
}
.prose :where(h4) {
color: #f3f4f6;
}
.prose :where(thead) {
color: #f3f4f6;
border-bottom-color: #374151;
}
.prose :where(tbody tr) {
border-bottom-color: #1f2937;
}
}
@media (prefers-color-scheme: dark){
.prose{
color: #f9fafb;
}
.prose :where([class~="lead"]) {
color: #9ca3af;
}
.prose :where(figcaption) {
color: #9ca3af !important;
}
.prose :where(strong) {
color: #f3f4f6;
}
.prose :where(ul > li) ::before{
background-color: #374151;
}
.prose :where(hr) {
border-color: #1f2937;
}
.prose :where(blockquote) {
color: #f3f4f6;
border-left-color: #1f2937;
}
.prose :where(h1) {
color: #f3f4f6;
}
.prose :where(h2) {
color: #f3f4f6;
}
.prose :where(h3) {
color: #f3f4f6;
}
.prose :where(h4) {
color: #f3f4f6;
}
.prose :where(code) {
color: #f3f4f6;
}
.prose :where(a code) {
color: inherit;
}
.prose :where(thead) {
color: #f3f4f6;
border-bottom-color: #374151;
}
.prose :where(tbody tr) {
border-bottom-color: #1f2937;
}
}

View File

@ -0,0 +1,2 @@
// hmm...
//export const csr = false;

49
src/routes/+layout.svelte Normal file
View File

@ -0,0 +1,49 @@
<script>
import 'modern-normalize/modern-normalize.css';
import '../app.scss';
import CommandLine from '$lib/CommandLine.svelte';
import { page } from '$app/stores';
import config from '$lib/config';
import constants from '$lib/constants';
</script>
<style lang="scss">
footer {
background: #0a0b0c;
color: #fff;
margin: max(12vmin,64px) 0 0;
padding: 0 max(4vmin,20px);
padding-bottom: 140px;
padding-top: 48px;
position: relative;
.inner {
color: hsla(0,0%,100%,.7);
font-size: 0.9rem;
text-align: center;
}
}
.inner {
margin: 0 auto;
max-width: 1200px;
width: 100%;
}
</style>
<header>
<CommandLine>
cd {(new URL($page.url)).pathname}
</CommandLine>
</header>
<slot/>
<footer>
<div class="inner">
<a href="https://cohost.org/{config.handle}/tagged/{config.tag}" target="_blank" rel="noopener noreferrer">view on cohost</a>
&middot;
<a href={constants.repo} target="_blank" rel="noopener noreferrer">powered by {constants.name}</a>
</div>
</footer>

View File

@ -0,0 +1,9 @@
import { getPosts } from '$lib/cohost';
/** @type {import('./$types').PageServerLoad} */
export async function load({ params }) {
const posts = await getPosts();
return {
posts: posts
};
}

223
src/routes/+page.svelte Normal file
View File

@ -0,0 +1,223 @@
<script>
import { assets } from '$app/paths';
import { renderPostPlaintext, renderPostSummaryMarkdown } from '$lib/markdown/rendering';
import { formatDate } from '$lib/utils';
import config from '$lib/config';
import { getPostImages, getPostMetadata, getPostPublishDate, getPostSlug } from '../lib/cohost';
import readingTime from 'reading-time/lib/reading-time';
import '../prose.scss';
/** @type {import('./$types').PageData} */
export let data;
</script>
<svelte:head>
<title>{config.blogName}</title>
<meta name="description" content={config.blogDescription}>
<link rel="icon" href="{assets}/favicon.png" type="image/png">
<link rel="canonical" href="{config.siteURL}/">
<meta property="og:site_name" content={config.blogName}>
<meta property="og:type" content="website">
<meta property="og:title" content={config.blogName}>
<meta property="og:description" content={config.blogDescription}>
<meta property="og:url" content="{config.siteURL}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content={config.blogName}>
<meta name="twitter:description" content={config.blogDescription}>
<meta name="twitter:url" content="{config.siteURL}/">
<meta name="description" content={config.blogDescription}>
<link rel="alternate" title={config.blogName} type="application/rss+xml" href="{config.siteURL}/rss.xml">
</svelte:head>
<style lang="scss">
.inner {
margin: 10px auto;
max-width: 1200px;
width: 100%;
}
.posts {
display: grid;
gap: 4.8vmin 4vmin;
grid-template-columns: repeat(6,1fr);
@media (max-width: 991px) {
grid-template-columns: 1fr 1fr;
}
padding: max(4.8vmin,36px) 0 0;
position: relative;
& > * {
grid-column: span 2;
}
}
.post-card {
background-color: var(--card-overlay);
border-radius: 1em;
overflow: hidden;
word-break: break-word;
.image {
display: block;
margin-bottom: 1.25em;
position: relative;
img {
height: 100%;
inset: 0;
-o-object-fit: cover;
object-fit: cover;
position: absolute;
width: 100%;
}
&::after {
content: "";
display: block;
padding-bottom: 55%;
}
}
header {
padding: 0 0.75rem;
.title {
font-size: 1.3rem;
font-weight: 800;
line-height: 1.2;
margin: 0;
margin-bottom: 0.25em;
}
.excerpt {
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
display: -webkit-box;
font-size: 0.8rem;
line-height: 1.5;
max-width: 720px;
overflow-y: hidden;
word-break: break-word;
}
}
.misc {
padding: 0.75rem;
color: var(--text-color-light);
}
display: flex;
flex-direction: column;
justify-content: space-between;
& > a {
flex: 0 0 auto;
min-height: 0;
display: block;
}
& > .misc {
max-height: 100%;
}
&:hover {
header {
opacity: 0.8;
}
}
}
.summary {
max-width: 750px;
margin: 0 auto;
padding: 1em 1em;
}
.header {
height: 460px;
max-height: 60vw;
position: relative;
background:
linear-gradient(to bottom, rgba(0,0,0,0), 85%, var(--background-color)),
fixed center / cover no-repeat var(--background-image);
.logo {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
vertical-align: middle;
max-width: 100%;
padding: 1em;
img {
animation: 2s infinite ease-in-out alternate light-bob;
}
}
}
@keyframes light-bob {
from {
transform: rotate(1deg);
}
to {
transform: rotate(-1deg);
}
}
</style>
<div class="header" style="--background-image: url('{assets}/banner.png')">
<div class="logo">
<img src="{assets}/logo.png" alt="logo">
</div>
</div>
<div class="inner">
<div class="summary prose">
<p>
<b>Hi!</b> Welcome to the dumping grounds for my thoughts, experiments, ideas and whatever else I decide to write up.
</p>
<p>
These aren't particularly well-organized, and I don't plan for them to be - I just write about whatever occupies my time or thoughts without
much regard for how interesting it might be. The things I generally tend to think about, however, tend to be tech or programming related, so
it's likely you'll see quite a bit of that here.
</p>
<p>
This site is also actually a proxy of my <a href="https://cohost.org/{config.handle}/tagged/{config.tag}" target="_blank" rel="noopener noreferrer">cohost page</a>!
The posts are just presented in a more convenient way, and are generally easier to embed and share.
</p>
</div>
<div class="posts">
{#each data.posts as post}
<article class="post-card">
<a href={'/' + getPostSlug(post)} class="link-wrapper">
{#if getPostImages(post).length > 0}
<div class="image">
<!-- dumb hack for variable declaration -->
{#each [getPostImages(post)[0]] as img}
<img src={img.previewURL} alt={img.altText} width={img.width} height={img.height}>
{/each}
</div>
{/if}
<header>
<h2 class="title">{post.headline}</h2>
<div class="post preview excerpt prose">
{@html renderPostSummaryMarkdown(post.blocks)}
</div>
</header>
</a>
<small class="misc">
{#if getPostPublishDate(post)}
<time datetime={getPostPublishDate(post)?.toISOString()}>{formatDate(getPostPublishDate(post))}</time>
&middot;
{/if}
{Math.ceil(readingTime(renderPostPlaintext(post.plainTextBody)).minutes)} minute read</small>
<!--{JSON.stringify(post)}-->
</article>
{/each}
</div>
</div>

View File

@ -0,0 +1,24 @@
import { getPostMetadata, getPosts, trpcRequest } from '$lib/cohost';
import { error } from '@sveltejs/kit';
export const trailingSlash = 'always';
/** @type {import('./$types').PageServerLoad} */
export async function load({ params }) {
const posts = await getPosts();
let post = posts.find(post => getPostMetadata(post).slug === params.post);
if (!post) post = posts.find(post => post.filename === params.post);
if (!post) throw error(404, {message: 'Post not found'});
const postId = post.postId;
const postFetched = await trpcRequest('posts.singlePost', {
handle: post.postingProject.handle,
postId: postId
});
// uh oh! let's just serve from the emptier post cache
if (!postFetched.result) return {post: post, comments: {[postId]: []}};
return postFetched.result.data;
}

View File

@ -0,0 +1,150 @@
<script>
import Comment from '$lib/Comment.svelte';
import { getPostImages, getPostPublishDate, getPostSlug, renderASTMap } from '$lib/cohost';
import { renderPostMarkdown, renderPostSummaryPlaintext } from '$lib/markdown/rendering';
import { formatDate } from '$lib/utils';
import * as timeago from 'timeago.js';
import '../../prose.scss';
import 'highlight.js/styles/github-dark-dimmed.css';
import { onMount } from 'svelte';
import config from '$lib/config';
import constants from '$lib/constants';
/** @type {import('./$types').PageData} */
export let data;
/**
* @type {Post}
*/
const post = data.post;
/**
* @type {Record<string, Comment[]>}
*/
const comments = data.comments;
let publishTimestampElement;
onMount(() => {
if (publishTimestampElement) {
timeago.render([publishTimestampElement]);
}
});
// metadata
const canonicalURL = `${config.siteURL}/${getPostSlug(post)}/`;
const summary = renderPostSummaryPlaintext(post.blocks);
const image = getPostImages(post)[0];
</script>
<svelte:head>
<title>{post.headline} &middot; {config.blogName}</title>
<meta name="description" content={summary}>
<link rel="canonical" href={canonicalURL}>
<meta property="og:site_name" content="{config.blogName}">
<meta property="og:type" content="article">
<meta property="og:title" content={post.headline}>
<meta property="og:description" content={summary}>
<meta property="og:url" content={canonicalURL}>
{#if image}
<meta property="og:image" content={image.fileURL}>
<meta property="og:image:width" content={image.width}>
<meta property="og:image:height" content={image.height}>
{/if}
{#if getPostPublishDate(post)}
<meta property="article:published_time" content={getPostPublishDate(post)?.toISOString()}>
{/if}
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'}>
<meta name="twitter:title" content={post.headline}>
<meta name="twitter:description" content={summary}>
<meta name="twitter:url" content={canonicalURL}>
{#if image}
<meta name="twitter:image" content={image.fileURL}>
{/if}
<meta name="generator" content={constants.name}>
</svelte:head>
<style lang="scss">
.inner {
max-width: 800px;
margin: 20px auto;
padding: 0 1em;
}
.post-container {
h1 {
margin-bottom: 0;
}
.note, .note a {
color: var(--text-color-light);
font-size: 0.8rem;
}
.image {
margin: 1rem auto;
img {
width: 100%;
height: auto;
}
}
}
.comments-label {
margin-top: 1em;
margin-bottom: 1em;
}
.comments {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
<div class="inner">
<div class="prose post-container">
<h1 class="title">{post.headline}</h1>
<span class="note">
<a rel="noreferrer nofollower" target="_blank" href={post.singlePostPageUrl}>
{#if getPostPublishDate(post)}
<time bind:this={publishTimestampElement} datetime={getPostPublishDate(post)?.toISOString()}>{timeago.format(getPostPublishDate(post))}</time>
{:else}
draft
{/if}
</a>
&middot;
by <a rel="noreferrer nofollower" target="_blank" href={post.postingProject.url}>{post.postingProject.displayName}</a>
</span>
{#if getPostImages(post).length > 0}
<div class="image">
<!-- dumb hack for variable declaration -->
{#each [getPostImages(post)[0]] as img}
<img src={img.previewURL} alt={img.altText} width={img.width} height={img.height}>
{/each}
</div>
{/if}
<div class="post">
{@html renderPostMarkdown(post.plainTextBody)}
</div>
</div>
<hr>
{#if getPostPublishDate(post)}
<small>published <time datetime={getPostPublishDate(post)?.toISOString()}>{formatDate(getPostPublishDate(post))}</time></small>
{:else}
<small>draft</small>
{/if}
<div class="comments-label">{post.numComments} comments &middot; <a href={post.singlePostPageUrl} target="_blank" rel="noreferrer noopener">join the discussion on cohost</a></div>
<div class="comments">
{#each comments[post.postId] as comment}
<Comment data={comment}/>
{/each}
</div>
</div>

View File

@ -0,0 +1,48 @@
import { getPostImages, getPostPublishDate, getPostSlug, getPosts } from '$lib/cohost';
import { renderPostMarkdown, renderPostSummaryPlaintext } from '$lib/markdown/rendering';
import { minify as minifyXML } from 'minify-xml';
import config from '$lib/config';
/** @type {import('./$types').RequestHandler} */
export async function GET() {
const posts = await getPosts();
let response = new Response(xml(posts));
response.headers.set('Cache-Control', 'max-age=0, s-maxage=3600');
response.headers.set('Content-Type', 'application/xml');
return response;
}
const xml = (/** @type {Post[]} */ posts) => minifyXML(`
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>${config.blogName}</title>
<description>${config.blogDescription}</description>
<link>${config.siteURL}/</link>
<link href="${config.siteURL}/rss.xml" rel="self" type="application/rss+xml" />
<ttl>60</ttl>
<image>
<url>${config.siteURL}/favicon.png</url>
<title>${config.blogName}</title>
<link>${config.siteURL}/</link>
</image>
${posts
.map(post => `
<item>
<title>${post.headline}</title>
<description>${renderPostSummaryPlaintext(post.blocks)}</description>
<link>${config.siteURL}/${getPostSlug(post)}/</link>
<dc:creator>${post.postingProject.displayName}</dc:creator>
<pubDate>${getPostPublishDate(post)?.toUTCString()}</pubDate>
${getPostImages(post).length > 0 ?
`<content url="${getPostImages(post)[0].fileURL}" medium="image" />`
: ''}
<content:encoded><![CDATA[
${renderPostMarkdown(post.plainTextBody, true)}
]]></content:encoded>
</item>
`)}
</channel>
</rss>
`);

View File

@ -0,0 +1,44 @@
import { getPostImages, getPostPublishDate, getPostSlug, getPosts } from '$lib/cohost';
import { minify as minifyXML } from 'minify-xml';
import config from '$lib/config';
/** @type {import('./$types').RequestHandler} */
export async function GET() {
const posts = await getPosts();
let response = new Response(xml(posts));
response.headers.set('Cache-Control', 'max-age=0, s-maxage=3600');
response.headers.set('Content-Type', 'application/xml');
return response;
}
const xml = (/** @type {Post[]} */ posts) => minifyXML(`
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:news="https://www.google.com/schemas/sitemap-news/0.9"
xmlns:xhtml="https://www.w3.org/1999/xhtml"
xmlns:mobile="https://www.google.com/schemas/sitemap-mobile/1.0"
xmlns:image="https://www.google.com/schemas/sitemap-image/1.1"
xmlns:video="https://www.google.com/schemas/sitemap-video/1.1"
>
<url>
<loc>${config.siteURL}</loc>
<changefreq>daily</changefreq>
<priority>0.7</priority>
</url>
${posts
.map(post => `
<url>
<loc>${config.siteURL}/${getPostSlug(post)}</loc>
<lastmod>${getPostPublishDate(post)?.toISOString()}</lastmod>
${getPostImages(post).length > 0 ? `
<image:image>
<image:loc>${getPostImages(post)[0].fileURL}</image:loc>
<image:caption>${post.headline}</image:caption>
</image:image>
` : ''}
</url>
`)}
</urlset>
`);

Binary file not shown.

Binary file not shown.

BIN
static/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -0,0 +1 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M414 158C414 70.797 343.203 0 256 0S98 70.797 98 158v196c0 87.203 70.797 158 158 158s158-70.797 158-158V158Z" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 313 B

View File

@ -0,0 +1 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M354 98c0-54.088-43.912-98-98-98s-98 43.912-98 98v316c0 54.088 43.912 98 98 98s98-43.912 98-98V98Z" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 303 B

3
static/masks/egg.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="500" height="366" viewBox="0 0 500 366" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M299.375 347.12C254.156 360.638 211.346 366.764 170.945 365.497C130.544 364.23 95.8817 354.689 66.9566 336.873C38.0313 319.059 18.0946 291.827 7.14576 255.178C-3.86568 218.32 -2.15088 184.558 12.2904 153.893C26.7319 123.227 50.5241 96.3814 83.6671 73.3558C116.81 50.3302 155.992 32.058 201.211 18.5394C246.221 5.08325 288.858 -1.04798 329.123 0.14595C369.387 1.33964 404.019 10.7761 433.017 28.455C462.016 46.1342 481.989 73.2979 492.938 109.947C503.887 146.595 502.104 180.32 487.589 211.122C473.075 241.924 449.329 268.926 416.354 292.13C383.378 315.334 344.385 333.664 299.375 347.12Z" fill="#FFF9F2"/>
</svg>

After

Width:  |  Height:  |  Size: 721 B

15
svelte.config.js Normal file
View File

@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(),
},
preprocess: [vitePreprocess()],
};
export default config;

6
vite.config.js Normal file
View File

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});