({ type: "projects" }),
28 | ] as const
29 |
30 | const [talks, posts, projects] = await Promise.all(promises)
31 |
32 | return {
33 | talks: Object.values(talks)
34 | .filter((talk) => !talk.hidden)
35 | .sort((a, b) => b.date.localeCompare(a.date)),
36 | posts,
37 | projects,
38 | }
39 | },
40 | render(context) {
41 | const announcement = banner(/* html */ `lucie->tokyo , my photography exhibition from Japan is live! Check it out now `)
42 |
43 | const hero = /* html */ `
44 |
45 | ${heading("Hi! Lucie here", { as: "h1", class: "heading-0" })}
46 |
47 | I'm an internet citizen, currently working as a
48 | DevExp Engineer @prismic.io
49 |
50 | I'm also a contributor and team member @nuxt.com ,
51 | enjoying open-source overall.
52 |
53 | Learn more about me and this place on the about page ,
54 | or appreciate the following~
55 |
56 | `
57 |
58 | const talks = /* html */ `
59 |
60 | ${heading("Talks", { as: "h2", class: "heading-2" })}
61 |
62 | I really enjoy speaking at conferences.
63 | I'm thankful for them to have me.
64 |
65 |
66 | Check out my past talks for resources, slides, and more -^
67 |
68 |
69 | ${context.data.talks
70 | .map((talk) => {
71 | return /* html */ `
72 |
73 |
74 | ${dateToUSDate(talk.date)}
75 |
76 |
79 | ${talk.title}
80 |
81 | `
82 | })
83 | .join("\n")}
84 |
85 | `
86 |
87 | const projects = /* html */ `
88 |
89 | ${heading("Projects", { as: "h2", class: "heading-2" })}
90 |
91 | I create an maintain different code projects during my free time.
92 |
93 |
94 | Here's a list of them, dates refer to first release -^
95 |
96 |
97 | ${Object.values(context.data.projects)
98 | .reverse()
99 | .map((project) => {
100 | return /* html */ `
101 |
102 |
103 | ${dateToUSDate(project.start)}
104 |
105 |
108 | ${project.title}
109 |
110 | `
111 | })
112 | .join("\n")}
113 |
114 | `
115 |
116 | const art = /* html */ `
117 |
118 | ${heading("Art", { as: "h2", class: "heading-2" })}
119 |
120 | I share now and then artists' work.
121 | Their work is really inspiring to me.
122 |
123 |
124 | If you'd like an artist to be shared here, or if you're one,
125 | let me know ~
126 |
127 |
128 | Your can browse all entries there ->
129 |
130 | `
131 |
132 | const formattedPosts = context.data.posts.map((post) => {
133 | return /* html */ `
134 |
135 |
136 | ${dateToUSDate(post.data.published_date)}
137 |
138 |
141 | ${prismic.asText(post.data.title)}
142 |
143 | `
144 | })
145 |
146 | const posts = /* html */ `
147 |
148 | ${heading("Posts", { as: "h2", class: "heading-2" })}
149 |
150 | I don't really write anymore.
151 | I went through tech stuff, tutorials, and experience feedback.
152 |
153 |
154 | Here's an archive of my old posts -^
155 |
156 |
157 | ${formattedPosts.join("\n")}
158 |
159 | `
160 |
161 | return base(
162 | [
163 | announcement,
164 | hero,
165 | nav({ currentPath: context.path }),
166 | talks,
167 | projects,
168 | art,
169 | posts,
170 | footer(),
171 | ].join("\n"),
172 | { path: context.path, script: "/assets/js/index.ts" },
173 | )
174 | },
175 | })
176 |
--------------------------------------------------------------------------------
/src/files/meteo.ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData } from "../akte/types"
2 |
3 | import { defineAkteFile } from "akte"
4 |
5 | import { heading } from "../components/heading"
6 | import { minimal } from "../layouts/minimal"
7 |
8 | export const meteo = defineAkteFile().from({
9 | path: "/meteo",
10 | render(context) {
11 | const hero = /* html */ `
12 | `
34 |
35 | const forecast = /* html */ ` `
36 |
37 | return minimal(
38 | [hero, forecast].join("\n"),
39 | { path: context.path, title: "Meteo", script: "/assets/js/meteo.ts" },
40 | )
41 | },
42 | })
43 |
--------------------------------------------------------------------------------
/src/files/notes/[slug].ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData, NoteData } from "../../akte/types"
2 |
3 | import { defineAkteFiles } from "akte"
4 | import { readAllDataHTML } from "../../akte/data"
5 | import { dateToUSDate } from "../../akte/date"
6 | import { slugify } from "../../akte/slufigy"
7 |
8 | import { heading } from "../../components/heading"
9 |
10 | import { minimal } from "../../layouts/minimal"
11 |
12 | export const slug = defineAkteFiles().from({
13 | path: "/notes/:slug",
14 | async bulkData() {
15 | const notes = await readAllDataHTML<{
16 | first_publication_date: string
17 | last_publication_date: string
18 | }>({ type: "notes" })
19 |
20 | const files: Record = {}
21 | for (const path in notes) {
22 | const title = path.split("/").pop()!.replace(".md", "")
23 | const data: NoteData = {
24 | ...notes[path].matter,
25 | title,
26 | body: notes[path].html,
27 | links: {
28 | outbound: notes[path].links.outbound,
29 | inbound: {},
30 | },
31 | }
32 |
33 | files[`/notes/${slugify(title)}`] = data
34 | }
35 |
36 | // Compute inbound links
37 | for (const path in files) {
38 | const file = files[path]
39 | for (const outboundLink of file.links.outbound) {
40 | if (outboundLink in files) {
41 | files[outboundLink].links.inbound[path] = {
42 | path,
43 | title: file.title,
44 | first_publication_date: file.first_publication_date,
45 | }
46 | }
47 | }
48 | }
49 |
50 | return files
51 | },
52 | async render(context) {
53 | const note = context.data
54 |
55 | const dates = []
56 | dates.push(
57 | /* html */ `First published: ${dateToUSDate(note.first_publication_date)} `,
60 | )
61 | if (note.first_publication_date !== note.last_publication_date) {
62 | dates.push(
63 | /* html */ `Last updated: ${dateToUSDate(note.last_publication_date)} `,
66 | )
67 | }
68 |
69 | const body = /* html */ `
70 |
71 | ${heading(note.title, { as: "h1" })}
72 | ${note.body}
73 | ${dates.join(" \n")}
74 | `
75 |
76 | const inboundNotes = Object.values(note.links.inbound).sort(
77 | (note1, note2) =>
78 | note2.first_publication_date.localeCompare(
79 | note1.first_publication_date,
80 | ),
81 | )
82 |
83 | const links = inboundNotes.length
84 | ? /* html */ `
85 |
86 | ${heading("Links to This Note", { as: "h2", class: "heading-2" })}
87 |
88 | ${inboundNotes
89 | .map((inboundNote) => {
90 | return /* html */ `
91 |
92 |
93 | ${dateToUSDate(inboundNote.first_publication_date)}
94 |
95 |
96 | ${inboundNote.title}
97 |
98 | `
99 | })
100 | .join("\n")}
101 |
102 | `
103 | : null
104 |
105 | return minimal([body, links].filter(Boolean).join("\n"), {
106 | path: context.path,
107 | title: note.title,
108 | })
109 | },
110 | })
111 |
--------------------------------------------------------------------------------
/src/files/notes/rss.ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData } from "../../akte/types"
2 |
3 | import { defineAkteFile } from "akte"
4 | import {
5 | NETLIFY,
6 | SITE_LANG,
7 | SITE_META_IMAGE,
8 | SITE_URL,
9 | } from "../../akte/constants"
10 | import { readAllDataHTML } from "../../akte/data"
11 | import { dateToISO } from "../../akte/date"
12 | import { slugify } from "../../akte/slufigy"
13 |
14 | export const rss = defineAkteFile().from({
15 | path: "/notes/rss.xml",
16 | async data() {
17 | const notes = await readAllDataHTML<{
18 | first_publication_date: string
19 | last_publication_date: string
20 | }>({ type: "notes" })
21 |
22 | return {
23 | notes: Object.keys(notes)
24 | .map((path) => {
25 | const title = path.split("/").pop()!.replace(".md", "")
26 |
27 | return {
28 | first_publication_date: notes[path].matter.first_publication_date,
29 | slug: slugify(title),
30 | title,
31 | }
32 | })
33 | .sort((note1, note2) =>
34 | note2.first_publication_date.localeCompare(
35 | note1.first_publication_date,
36 | ),
37 | ),
38 | }
39 | },
40 | render(context) {
41 | const items = context.data.notes
42 | .map((note) => {
43 | const url = `${SITE_URL}/notes/${note.slug}`
44 |
45 | const title = note.title
46 |
47 | const pubDate = note.first_publication_date
48 |
49 | return /* xml */ ` -
50 |
51 | ${url}
52 | ${url}
53 | ${dateToISO(pubDate)}
54 | "${title}" is available, you can check it out here .]]>
55 | `
56 | })
57 | .join("\n")
58 |
59 | return /* xml */ `
60 |
61 |
62 |
63 | ${SITE_URL}/notes/rss.xml
64 |
65 | ${dateToISO(NETLIFY.buildTime)}
66 | https://validator.w3.org/feed/docs/rss2.html
67 | ${SITE_LANG}
68 |
69 |
70 | ${SITE_META_IMAGE.openGraph}
71 | ${SITE_URL}/notes/rss.xml
72 |
73 | ${items}
74 |
75 |
76 | `
77 | },
78 | })
79 |
--------------------------------------------------------------------------------
/src/files/posts/[slug].ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData } from "../../akte/types"
2 | import * as prismic from "@prismicio/client"
3 | import { defineAkteFiles, NotFoundError } from "akte"
4 |
5 | import escapeHTML from "escape-html"
6 | import {
7 | SITE_MAIN_AUTHOR,
8 | SITE_META_IMAGE,
9 | SITE_TITLE,
10 | SITE_URL,
11 | } from "../../akte/constants"
12 | import { dateToUSDate } from "../../akte/date"
13 | import { asyncAsHTML, getClient } from "../../akte/prismic"
14 |
15 | import { heading } from "../../components/heading"
16 |
17 | import { page } from "../../layouts/page"
18 |
19 | export const slug = defineAkteFiles().from({
20 | path: "/posts/:slug",
21 | async data(context) {
22 | const post = await getClient().getByUID("post__blog", context.params.slug)
23 |
24 | if (!post) {
25 | throw new NotFoundError(context.path)
26 | }
27 |
28 | return post
29 | },
30 | async bulkData() {
31 | const posts = await getClient().getAllByType("post__blog")
32 |
33 | const files: Record = {}
34 | for (const post of posts) {
35 | if (!post.url) {
36 | throw new Error(
37 | `Unable to resolve URL for document: ${JSON.stringify(post)}`,
38 | )
39 | }
40 | files[post.url] = post
41 | }
42 |
43 | return files
44 | },
45 | async render(context) {
46 | const post = context.data
47 |
48 | const title = prismic.asText(post.data.title) || "unknown"
49 | const lead = prismic.asText(post.data.lead)
50 | const body = await asyncAsHTML(post.data.body)
51 |
52 | const pubDate = post.data.published_date
53 | const category = post.data.category
54 | const thumbnail = prismic.asImageSrc(post.data.thumbnail, {
55 | rect: undefined,
56 | w: undefined,
57 | h: undefined,
58 | })
59 |
60 | const slot = /* html */ `
61 |
87 |
88 | ${body}
89 | `
90 |
91 | const meta = {
92 | title: post.data.meta_title,
93 | description: post.data.meta_description,
94 | image: { openGraph: post.data.meta_image?.url },
95 | structuredData: [
96 | {
97 | "@context": "http://schema.org",
98 | "@type": "BlogPosting",
99 |
100 | "mainEntityOfPage": {
101 | "@type": "WebSite",
102 | "@id": escapeHTML(SITE_URL),
103 | },
104 |
105 | "url": escapeHTML(`${SITE_URL}${post.url}`),
106 | "name": escapeHTML(title),
107 | "alternateName": escapeHTML(SITE_TITLE),
108 | "headline": escapeHTML(title),
109 | "image": escapeHTML(
110 | post.data.meta_image?.url ?? SITE_META_IMAGE.openGraph,
111 | ),
112 | "description": escapeHTML(lead),
113 | "datePublished": escapeHTML(pubDate),
114 | "dateModified": escapeHTML(post.last_publication_date),
115 |
116 | "author": {
117 | "@type": "Person",
118 | "name": escapeHTML(SITE_MAIN_AUTHOR),
119 | },
120 |
121 | "publisher": {
122 | "@type": "Organization",
123 | "url": escapeHTML(SITE_URL),
124 | "logo": {
125 | "@type": "ImageObject",
126 | "url": escapeHTML(SITE_META_IMAGE.openGraph),
127 | },
128 | "name": escapeHTML(SITE_TITLE),
129 | },
130 | },
131 | ],
132 | }
133 |
134 | return page(slot, { path: context.path, ...meta })
135 | },
136 | })
137 |
--------------------------------------------------------------------------------
/src/files/posts/rss.ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData } from "../../akte/types"
2 | import * as prismic from "@prismicio/client"
3 | import { defineAkteFile } from "akte"
4 |
5 | import escapeHTML from "escape-html"
6 | import {
7 | NETLIFY,
8 | SITE_LANG,
9 | SITE_META_IMAGE,
10 | SITE_URL,
11 | } from "../../akte/constants"
12 | import { dateToISO } from "../../akte/date"
13 | import { getClient } from "../../akte/prismic"
14 |
15 | export const rss = defineAkteFile().from({
16 | path: "/posts/rss.xml",
17 | async data() {
18 | const posts = await getClient().getAllByType("post__blog", {
19 | orderings: {
20 | field: "my.post__blog.published_date",
21 | direction: "desc",
22 | },
23 | })
24 |
25 | return { posts }
26 | },
27 | render(context) {
28 | const items = context.data.posts
29 | .map((post) => {
30 | const url = `${SITE_URL}${post.url}`
31 |
32 | const title = prismic.asText(post.data.title)
33 | const lead = prismic.asText(post.data.lead)
34 |
35 | const pubDate = post.data.published_date
36 | const thumbnail = escapeHTML(prismic.asImageSrc(post.data.meta_image))
37 |
38 | return /* xml */ ` -
39 |
40 | ${url}
41 | ${url}
42 | ${dateToISO(pubDate)}
43 | "${title}" is available, you can check it out here . ${lead}]]>
44 |
45 | `
46 | })
47 | .join("\n")
48 |
49 | return /* xml */ `
50 |
51 |
52 |
53 | ${SITE_URL}/posts/rss.xml
54 |
55 | ${dateToISO(NETLIFY.buildTime)}
56 | https://validator.w3.org/feed/docs/rss2.html
57 | ${SITE_LANG}
58 |
59 |
60 | ${SITE_META_IMAGE.openGraph}
61 | ${SITE_URL}/posts/rss.xml
62 |
63 | ${items}
64 |
65 |
66 | `
67 | },
68 | })
69 |
--------------------------------------------------------------------------------
/src/files/preview.ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData } from "../akte/types"
2 |
3 | import { defineAkteFile } from "akte"
4 |
5 | import { heading } from "../components/heading"
6 |
7 | import { minimal } from "../layouts/minimal"
8 |
9 | export const preview = defineAkteFile().from({
10 | path: "/preview",
11 | render(context) {
12 | const slot = /* html */ `
13 | `
19 |
20 | return minimal(slot, { path: context.path, title: "Loading Preview" })
21 | },
22 | })
23 |
--------------------------------------------------------------------------------
/src/files/private/[slug].ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData } from "../../akte/types"
2 |
3 | import process from "node:process"
4 | import * as prismic from "@prismicio/client"
5 |
6 | import { defineAkteFiles, NotFoundError } from "akte"
7 | import { dateToUSDate } from "../../akte/date"
8 | import { asHTML, asyncAsHTML, getClient } from "../../akte/prismic"
9 | import { sha256 } from "../../akte/sha256"
10 |
11 | import { heading } from "../../components/heading"
12 | import { notIndexed } from "../../components/notIndexed"
13 |
14 | import { minimal } from "../../layouts/minimal"
15 |
16 | export const slug = defineAkteFiles().from({
17 | path: "/private/:slugWithHash",
18 | async data(context) {
19 | const [hash, ...guls] = context.params.slugWithHash.split("-").reverse()
20 | const slug = guls.reverse().join("-")
21 |
22 | if (hash !== await sha256(slug, process.env.PRISMIC_TOKEN!, 7)) {
23 | throw new NotFoundError(context.path)
24 | }
25 |
26 | const doc = await getClient().getByUID("post__document--private", slug)
27 |
28 | if (!doc) {
29 | throw new NotFoundError(context.path)
30 | }
31 |
32 | return doc
33 | },
34 | async bulkData() {
35 | const docs = await getClient().getAllByType("post__document--private")
36 |
37 | const files: Record = {}
38 | for (const doc of docs) {
39 | if (!doc.url) {
40 | throw new Error(
41 | `Unable to resolve URL for document: ${JSON.stringify(doc)}`,
42 | )
43 | }
44 | files[`${doc.url}-${await sha256(doc.uid!, process.env.PRISMIC_TOKEN!, 7)}`] = doc
45 | }
46 |
47 | return files
48 | },
49 | async render(context) {
50 | const doc = context.data
51 |
52 | const title = prismic.asText(doc.data.title) || "unknown"
53 | const lead = asHTML(doc.data.lead)
54 | const body = await asyncAsHTML(doc.data.body)
55 |
56 | const pubDate = doc.last_publication_date
57 |
58 | const slot = /* html */ `
59 | ${notIndexed(context.path)}
60 |
61 | ${heading(title, { as: "h1" })}
62 | ${lead}
63 |
64 |
65 | ${body}
66 |
67 | Last updated: ${dateToUSDate(pubDate)}
68 |
69 | `
70 |
71 | const meta = {
72 | title: doc.data.meta_title,
73 | description: doc.data.meta_description,
74 | image: { openGraph: doc.data.meta_image?.url },
75 | }
76 |
77 | return minimal(slot, { path: context.path, ...meta, noindex: true })
78 | },
79 | })
80 |
--------------------------------------------------------------------------------
/src/files/records.ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData } from "../akte/types"
2 |
3 | import { defineAkteFile } from "akte"
4 | import { getAllReleasesSafely } from "../akte/discogs"
5 |
6 | import { heading } from "../components/heading"
7 |
8 | import { page } from "../layouts/page"
9 |
10 | export const records = defineAkteFile().from({
11 | path: "/records",
12 | async data() {
13 | const releases = await getAllReleasesSafely()
14 |
15 | return { releases }
16 | },
17 | render(context) {
18 | const hero = /* html */ `
19 | `
26 |
27 | const desktop = /* html */ `
28 |
29 |
30 | Use column header buttons to sort
31 |
32 |
33 |
34 | Title
35 |
36 |
37 | Artist
38 |
39 |
40 | Genres
41 |
42 |
43 | Year
44 |
45 |
46 |
47 |
48 |
49 | ${context.data.releases
50 | .map((release) => {
51 | const id = release.basic_information.id
52 | const image = release.basic_information.cover_image
53 | const title = release.basic_information.title
54 | const artist = release.basic_information.artists[0].name.replace(
55 | /\(\d+\)$/,
56 | "",
57 | )
58 | const genre = release.basic_information.genres.sort().join(", ")
59 | const year = release.basic_information.year
60 |
61 | return /* html */ `
62 |
63 |
64 |
70 | ${title}
71 |
72 | ${artist}
73 | ${genre}
74 |
75 | ${year ? `${year} ` : "n/a"}
76 |
77 |
78 | Discogs
79 |
80 | `
81 | })
82 | .join("\n")}
83 |
84 |
85 |
`
86 |
87 | const mobile = /* html */ `
88 |
89 | ${context.data.releases
90 | .map((release) => {
91 | const id = release.basic_information.id
92 | const image = release.basic_information.thumb
93 | const title = release.basic_information.title
94 | const artist = release.basic_information.artists[0].name.replace(
95 | /\(\d+\)$/,
96 | "",
97 | )
98 | const genre = release.basic_information.genres.sort().join(", ")
99 | const year = release.basic_information.year
100 |
101 | return /* html */ `
102 |
103 |
109 |
110 |
111 |
Title - Artist
112 | ${title} - ${artist}
113 |
114 |
115 |
Genre - Year
116 |
117 | ${genre} - ${year ? `${year} ` : "n/a"}
118 |
119 |
120 |
124 |
125 | `
126 | })
127 | .join("\n")}
128 | `
129 |
130 | return page([hero, desktop, mobile].join("\n"), {
131 | path: context.path,
132 | title: "Records",
133 | script: "/assets/js/records.ts",
134 | })
135 | },
136 | })
137 |
--------------------------------------------------------------------------------
/src/files/sitemap.ts:
--------------------------------------------------------------------------------
1 | import type * as prismic from "@prismicio/client"
2 | import type { GlobalData, TalkData } from "../akte/types"
3 |
4 | import { defineAkteFile } from "akte"
5 | import { NETLIFY, SITE_URL } from "../akte/constants"
6 | import { readAllDataHTML, readAllDataJSON } from "../akte/data"
7 | import { dateToISO } from "../akte/date"
8 | import { getClient } from "../akte/prismic"
9 | import { slugify } from "../akte/slufigy"
10 |
11 | export const sitemap = defineAkteFile().from({
12 | path: "/sitemap.xml",
13 | async data() {
14 | const mapPrismicDocuments = (
15 | docs: prismic.PrismicDocument[],
16 | ): { loc: string, lastMod: string | number }[] => {
17 | return docs.map((doc) => {
18 | return {
19 | loc: `${SITE_URL}${doc.url}`,
20 | lastMod: doc.last_publication_date || NETLIFY.buildTime,
21 | }
22 | })
23 | }
24 |
25 | const promises = [
26 | readAllDataJSON({ type: "talks" }),
27 | readAllDataHTML<{
28 | first_publication_date: string
29 | last_publication_date: string
30 | }>({ type: "notes" }),
31 | getClient().getAllByType("post__blog"),
32 | getClient().getAllByType("post__document"),
33 | ] as const
34 |
35 | const [talks, notes, posts, documents] = await Promise.all(promises)
36 |
37 | return {
38 | pages: [
39 | { loc: SITE_URL, lastMod: NETLIFY.buildTime },
40 | { loc: `${SITE_URL}/colors`, lastMod: NETLIFY.buildTime },
41 | { loc: `${SITE_URL}/records`, lastMod: NETLIFY.buildTime },
42 | { loc: `${SITE_URL}/meteo`, lastMod: NETLIFY.buildTime },
43 | { loc: `${SITE_URL}/code`, lastMod: NETLIFY.buildTime },
44 | { loc: `${SITE_URL}/art`, lastMod: NETLIFY.buildTime },
45 | { loc: `${SITE_URL}/albums`, lastMod: NETLIFY.buildTime },
46 | { loc: `${SITE_URL}/talks/poll`, lastMod: NETLIFY.buildTime },
47 | ...mapPrismicDocuments(posts),
48 | ...mapPrismicDocuments(documents),
49 | ...Object.values(talks).map((talk) => {
50 | return {
51 | loc: `${SITE_URL}/talks/${talk.conference.slug}/${talk.slug}`,
52 | lastMod: NETLIFY.buildTime,
53 | }
54 | }),
55 | ...Object.keys(notes).map((path) => {
56 | const title = path.split("/").pop()!.replace(".md", "")
57 |
58 | return {
59 | loc: `${SITE_URL}/notes/${slugify(title)}`,
60 | lastMod: notes[path].matter.last_publication_date,
61 | }
62 | }),
63 | ],
64 | }
65 | },
66 | render(context) {
67 | const urls = context.data.pages
68 | .map((page) => {
69 | return /* xml */ `
70 | ${page.loc}
71 | ${dateToISO(page.lastMod)}
72 | `
73 | })
74 | .join("\n")
75 |
76 | return /* xml */ `
77 |
78 | ${urls}
79 |
80 | `
81 | },
82 | })
83 |
--------------------------------------------------------------------------------
/src/files/talks/[conference]/[slug].ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData, TalkData } from "../../../akte/types"
2 |
3 | import { defineAkteFiles } from "akte"
4 | import { readAllDataJSON } from "../../../akte/data"
5 | import { dateToUSDate } from "../../../akte/date"
6 |
7 | import { heading } from "../../../components/heading"
8 |
9 | import { page } from "../../../layouts/page"
10 |
11 | export const slug = defineAkteFiles().from({
12 | path: "/talks/:conference/:slug",
13 | async bulkData() {
14 | const talks = await readAllDataJSON({ type: "talks" })
15 |
16 | const files: Record = {}
17 | for (const talk of Object.values(talks)) {
18 | files[`/talks/${talk.conference.slug}/${talk.slug}`] = talk
19 | }
20 |
21 | return files
22 | },
23 | async render(context) {
24 | const talk = context.data
25 |
26 | const marquee = /* html */ `Thanks for joining my talk! `
29 |
30 | const hero = /* html */ `
31 | `
55 |
56 | const links = /* html */ `
57 |
58 | ${heading("Resources", { as: "h2", class: "heading-2" })}
59 |
60 | ${talk.links
61 | .map((link) => {
62 | return /* html */ `
63 |
64 |
65 | ${link.name}
66 |
67 | `
68 | })
69 | .join("\n")}
70 |
71 | `
72 |
73 | const feedback = /* html */ `
74 |
75 | ${heading("Any feedback? Drop me a line~", { as: "h2", class: "heading-2" })}
76 |
77 | I'd love to hear your thoughts, whether about my talk or anything else on your mind! You can reach out to me on any of the following platforms.
78 |
79 |
90 | `
91 |
92 | const meta = {
93 | title: talk.title,
94 | description: `Resources from my talk during ${talk.conference.name}`,
95 | }
96 |
97 | return page([marquee, hero, links, feedback].join("\n"), {
98 | path: context.path,
99 | ...meta,
100 | script: "/assets/js/talks_conference_slug.ts",
101 | })
102 | },
103 | })
104 |
--------------------------------------------------------------------------------
/src/files/talks/poll.ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData } from "../../akte/types"
2 |
3 | import { defineAkteFile } from "akte"
4 |
5 | import { heading } from "../../components/heading"
6 |
7 | import { minimal } from "../../layouts/minimal"
8 |
9 | export const poll = defineAkteFile().from({
10 | path: "/talks/poll",
11 | render(context) {
12 | const slot = /* html */ `
13 |
14 | ${heading("Poll", { as: "h1", class: "heading-1" })}
15 |
16 | Thanks for voting, your answer has been recorded.
17 |
18 |
19 | Other questions might be up soon, stay tuned!
20 |
21 | `
22 |
23 | return minimal(slot, { path: context.path, title: "Poll" })
24 | },
25 | })
26 |
--------------------------------------------------------------------------------
/src/files/talks/rss.ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData, TalkData } from "../../akte/types"
2 |
3 | import { defineAkteFile } from "akte"
4 | import {
5 | NETLIFY,
6 | SITE_LANG,
7 | SITE_META_IMAGE,
8 | SITE_URL,
9 | } from "../../akte/constants"
10 | import { readAllDataJSON } from "../../akte/data"
11 | import { dateToISO } from "../../akte/date"
12 |
13 | export const rss = defineAkteFile().from({
14 | path: "/talks/rss.xml",
15 | async data() {
16 | const talks = await readAllDataJSON({ type: "talks" })
17 |
18 | return { talks: Object.values(talks).reverse() }
19 | },
20 | render(context) {
21 | const items = context.data.talks
22 | .map((talk) => {
23 | const url = `${SITE_URL}/talks/${talk.conference.slug}/${talk.slug}`
24 |
25 | const title = talk.title
26 | const lead = talk.lead
27 |
28 | const pubDate = talk.date
29 |
30 | return /* xml */ ` -
31 |
32 | ${url}
33 | ${url}
34 | ${dateToISO(pubDate)}
35 | "${title}" is available, you can check it out here . ${lead}]]>
36 | `
37 | })
38 | .join("\n")
39 |
40 | return /* xml */ `
41 |
42 |
43 |
44 | ${SITE_URL}/talks/rss.xml
45 |
46 | ${dateToISO(NETLIFY.buildTime)}
47 | https://validator.w3.org/feed/docs/rss2.html
48 | ${SITE_LANG}
49 |
50 |
51 | ${SITE_META_IMAGE.openGraph}
52 | ${SITE_URL}/talks/rss.xml
53 |
54 | ${items}
55 |
56 |
57 | `
58 | },
59 | })
60 |
--------------------------------------------------------------------------------
/src/functions.server.ts:
--------------------------------------------------------------------------------
1 | import type { Handler, HandlerContext, HandlerEvent } from "@netlify/functions"
2 | import {
3 | appendResponseHeaders,
4 | createApp,
5 | createRouter,
6 | defineEventHandler,
7 | getHeaders,
8 | getQuery,
9 | getRequestURL,
10 | readRawBody,
11 | sendRedirect,
12 | setResponseStatus,
13 | } from "h3"
14 |
15 | import { handler as admin } from "./functions/admin"
16 | import { handler as hr } from "./functions/hr"
17 | import { handler as poll } from "./functions/poll"
18 | import { handler as pollKeepalive } from "./functions/poll-keepalive"
19 | import { handler as preview } from "./functions/preview"
20 |
21 | import "dotenv/config"
22 |
23 | const app = createApp()
24 | const router = createRouter()
25 | app.use(router)
26 |
27 | function serve(handler: Handler) {
28 | return defineEventHandler(async (event) => {
29 | const url = getRequestURL(event)
30 | const headers = getHeaders(event)
31 | const rawQuery = getQuery(event)
32 | const [query, multiQuery] = Object.entries(rawQuery).reduce<[Record, Record]>((acc, [key, value]) => {
33 | if (Array.isArray(value)) {
34 | acc[1][key] = value.map((v) => `${v}`)
35 | } else {
36 | acc[0][key] = `${value}`
37 | }
38 |
39 | return acc
40 | }, [{}, {}])
41 | const body = event.method !== "GET" ? await readRawBody(event) ?? null : null
42 |
43 | const netlifyEvent: HandlerEvent = {
44 | rawUrl: url.toString(),
45 | rawQuery: url.search,
46 | path: url.pathname,
47 | httpMethod: event.method,
48 | headers,
49 | multiValueHeaders: {},
50 | queryStringParameters: query,
51 | multiValueQueryStringParameters: multiQuery,
52 | body,
53 | isBase64Encoded: false,
54 | }
55 |
56 | const response = await handler(netlifyEvent, {} as HandlerContext)
57 |
58 | if (response) {
59 | appendResponseHeaders(event, response.headers ?? {})
60 | setResponseStatus(event, response.statusCode ?? 200)
61 |
62 | if (response.headers?.location) {
63 | return sendRedirect(event, response.headers?.location as string, response.statusCode)
64 | }
65 | return response.body || ""
66 | }
67 | })
68 | }
69 |
70 | router.use("/admin", serve(admin))
71 | router.use("/api/hr", serve(hr))
72 | router.use("/api/poll", serve(poll))
73 | router.use("/api/poll-keepalive", serve(pollKeepalive))
74 | router.use("/preview", serve(preview))
75 | router.use("/preview/**", serve(preview))
76 |
77 | export { app }
78 |
--------------------------------------------------------------------------------
/src/functions/admin/admin.akte.app.ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData } from "../../akte/types"
2 |
3 | import { defineAkteApp } from "akte"
4 |
5 | import { fourOFour } from "../../files/404"
6 | import { admin } from "./files/admin"
7 |
8 | export const app = defineAkteApp({
9 | files: [fourOFour, admin],
10 | globalData() {
11 | return {}
12 | },
13 | })
14 |
--------------------------------------------------------------------------------
/src/functions/admin/files/admin.ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData } from "../../../akte/types"
2 |
3 | import process from "node:process"
4 | import * as prismic from "@prismicio/client"
5 | import { defineAkteFile } from "akte"
6 |
7 | import { dateToUSDate } from "../../../akte/date"
8 | import { getClient } from "../../../akte/prismic"
9 | import { sha256 } from "../../../akte/sha256"
10 |
11 | import { heading } from "../../../components/heading"
12 |
13 | import { minimal } from "../../../layouts/minimal"
14 |
15 | export const admin = defineAkteFile().from({
16 | path: "/admin",
17 | async data() {
18 | let [docs, albums] = await Promise.all([
19 | getClient().getAllByType("post__document--private"),
20 | getClient().getAllByType("post__album"),
21 | ])
22 |
23 | for (const doc of docs) {
24 | if (!doc.url) {
25 | throw new Error(
26 | `Unable to resolve URL for doc: ${JSON.stringify(doc)}`,
27 | )
28 | }
29 | doc.url = `${doc.url}-${await sha256(doc.uid!, process.env.PRISMIC_TOKEN!, 7)}`
30 | }
31 | docs = docs.sort((a, b) => b.first_publication_date.localeCompare(a.first_publication_date))
32 |
33 | for (const album of albums) {
34 | if (!album.url) {
35 | throw new Error(
36 | `Unable to resolve URL for album: ${JSON.stringify(album)}`,
37 | )
38 | }
39 | album.url = `${album.url}-${await sha256(album.uid!, process.env.PRISMIC_TOKEN!, 7)}`
40 | }
41 | albums = albums.sort((a, b) => b.data.published_date.localeCompare(a.data.published_date))
42 |
43 | return { albums, docs }
44 | },
45 | render(context) {
46 | const hero = /* html */ `
47 |
48 | ${heading("Admin", { as: "h1", class: "heading-1" })}
49 |
50 | Manage private pages and data.
51 |
52 | `
53 |
54 | const tools = /* html */ `
55 |
56 | ${heading("Tools", { as: "h2", class: "heading-2" })}
57 |
58 | Useful tools.
59 |
60 |
61 | Code -
62 | Colors -
63 | Meteo -
64 | Records
65 |
66 | `
67 |
68 | const docs = /* html */ `
69 |
70 | ${heading("Documents", { as: "h2", class: "heading-2" })}
71 |
72 | Private documents.
73 |
74 |
87 | `
88 |
89 | const albums = /* html */ `
90 |
91 | ${heading("Albums", { as: "h2", class: "heading-2" })}
92 |
93 | Private albums.
94 |
95 |
108 | `
109 |
110 | const script = /* html */ ``
111 |
112 | return minimal(
113 | [
114 | hero,
115 | tools,
116 | docs,
117 | albums,
118 | script,
119 | ].join("\n"),
120 | { path: context.path, title: "Admin" },
121 | )
122 | },
123 | })
124 |
--------------------------------------------------------------------------------
/src/functions/admin/index.ts:
--------------------------------------------------------------------------------
1 | import type { Handler, HandlerEvent } from "@netlify/functions"
2 |
3 | import { Buffer } from "node:buffer"
4 | import process from "node:process"
5 |
6 | import { RateLimiter } from "../../akte/lib/RateLimiter"
7 | import { sha256 } from "../../akte/sha256"
8 | import { app } from "./admin.akte.app"
9 |
10 | const JSON_HEADERS = {
11 | "content-type": "application/json",
12 | }
13 |
14 | const HTML_HEADERS = {
15 | "content-type": "text/html; charset=utf-8",
16 | }
17 |
18 | // const SESSION_NAME = "lihbr-session" as const
19 | // const SESSION_EXPIRY = 3_600_000
20 | // const ACTIVE_SESSIONS: Map = new Map()
21 |
22 | const rateLimiter = new RateLimiter({
23 | cache: new Map(),
24 | options: { limit: 6, window: 3_600_000 },
25 | })
26 |
27 | async function authenticate(event: HandlerEvent): Promise<{ session: string } | false> {
28 | if (!event.headers.authorization) {
29 | // const cookies = cookie.parse(event.headers.cookie || "")
30 |
31 | // const expiresAt = ACTIVE_SESSIONS.get(cookies[SESSION_NAME])
32 | // if (expiresAt) {
33 | // if (expiresAt > Date.now()) {
34 | // return { session: cookies[SESSION_NAME] }
35 | // }
36 |
37 | // ACTIVE_SESSIONS.delete(cookies[SESSION_NAME])
38 | // }
39 |
40 | return false
41 | }
42 |
43 | const [username, password] = Buffer.from(
44 | event.headers.authorization.split(" ").pop()!,
45 | "base64",
46 | ).toString().split(":")
47 |
48 | const credentials = `${username}:${await sha256(password, process.env.PRISMIC_TOKEN!)}`
49 | if (credentials === process.env.APP_ADMIN_CREDENTIALS) {
50 | const session = await sha256(credentials, `${process.env.PRISMIC_TOKEN}:${Date.now()}`)
51 | // ACTIVE_SESSIONS.set(session, Date.now() + SESSION_EXPIRY)
52 |
53 | return { session }
54 | }
55 |
56 | return false
57 | }
58 |
59 | export const handler: Handler = async (event) => {
60 | if (event.httpMethod.toUpperCase() !== "GET") {
61 | return {
62 | statusCode: 400,
63 | headers: { ...JSON_HEADERS },
64 | body: JSON.stringify({ error: "Bad Request" }),
65 | }
66 | }
67 |
68 | const user = await authenticate(event)
69 | if (!user) {
70 | const usage = rateLimiter.trackUsage(event)
71 |
72 | if (usage.hasReachedLimit) {
73 | return {
74 | statusCode: 429,
75 | headers: { ...JSON_HEADERS, ...usage.headers },
76 | body: JSON.stringify({ error: "Too Many Requests" }),
77 | }
78 | }
79 |
80 | return {
81 | statusCode: 401,
82 | headers: {
83 | ...HTML_HEADERS,
84 | "www-authenticate": "Basic realm=\"Access to the admin area\"",
85 | },
86 | body: await app.render(app.lookup("/404")),
87 | }
88 | }
89 |
90 | // const url = getSiteURL()
91 | // const cookies = cookie.serialize(SESSION_NAME, user.session, {
92 | // domain: url.startsWith("https://") ? url.replace(/^(https:\/\/)/, "").replace(/\/$/, "") : undefined,
93 | // path: "/admin",
94 | // expires: new Date(ACTIVE_SESSIONS.get(user.session)!),
95 | // httpOnly: true,
96 | // secure: url.startsWith("https://"),
97 | // sameSite: "strict",
98 | // })
99 |
100 | return {
101 | statusCode: 200,
102 | headers: { ...HTML_HEADERS },
103 | body: await app.render(app.lookup("/admin")),
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/functions/hr/index.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from "@netlify/functions"
2 |
3 | const JSON_HEADERS = {
4 | "content-type": "application/json",
5 | }
6 |
7 | const COLORS = [
8 | "#54669c",
9 | "#a54a5e",
10 | "#e84311",
11 | "#f27502",
12 | "#ffb005",
13 | "#759f53",
14 | ]
15 |
16 | export const handler: Handler = async (event) => {
17 | if (event.httpMethod.toUpperCase() !== "GET") {
18 | return {
19 | statusCode: 400,
20 | // Netlify is not really helpful with its Handler type here...
21 | headers: { ...JSON_HEADERS } as Record,
22 | body: JSON.stringify({ error: "Bad Request" }),
23 | }
24 | }
25 |
26 | return {
27 | statusCode: 200,
28 | headers: {
29 | "content-type": "image/svg+xml",
30 | "cache-control": "public, max-age=2, must-revalidate",
31 | },
32 | body: /* html */ ` `,
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/functions/poll-keepalive/index.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from "@netlify/functions"
2 |
3 | import process from "node:process"
4 |
5 | const JSON_HEADERS = {
6 | "content-type": "application/json",
7 | }
8 |
9 | function upstash(endpoint: string, body?: string): Promise {
10 | const url = new URL(endpoint, process.env.UPSTASH_ENDPOINT!)
11 |
12 | const method = body ? "POST" : "GET"
13 | const headers: Record = body ? { ...JSON_HEADERS } : {}
14 | headers.authorization = `Bearer ${process.env.UPSTASH_TOKEN}`
15 |
16 | return fetch(url.toString(), {
17 | body,
18 | method,
19 | headers,
20 | })
21 | }
22 |
23 | export const handler: Handler = async (event) => {
24 | if (event.httpMethod.toUpperCase() !== "POST") {
25 | return {
26 | statusCode: 400,
27 | headers: { ...JSON_HEADERS },
28 | body: JSON.stringify({ error: "Bad Request" }),
29 | }
30 | }
31 |
32 | await upstash(`./set/ping/${Date.now()}`)
33 |
34 | const res = await upstash(`./get/ping`)
35 | const json = await res.json()
36 |
37 | if (
38 | !res.ok ||
39 | typeof json !== "object" ||
40 | !json ||
41 | !("result" in json) ||
42 | !Array.isArray(json.result)
43 | ) {
44 | throw new Error(JSON.stringify(json))
45 | }
46 |
47 | await fetch(process.env.SLACK_NETLIFY_WEBHOOK!, {
48 | headers: { ...JSON_HEADERS },
49 | method: "POST",
50 | body: JSON.stringify({
51 | text: "New keep alive report~",
52 | blocks: [{
53 | type: "section",
54 | text: {
55 | type: "mrkdwn",
56 | text: `:bouquet: Kept alive at: '${JSON.stringify(json.result)}'`,
57 | },
58 | }],
59 | }),
60 | })
61 |
62 | return {
63 | statusCode: 200,
64 | headers: { ...JSON_HEADERS },
65 | body: JSON.stringify({}),
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/functions/poll/index.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from "@netlify/functions"
2 |
3 | import process from "node:process"
4 |
5 | const JSON_HEADERS = {
6 | "content-type": "application/json",
7 | }
8 |
9 | function GET_CORS_HEADERS(origin = ""): Record {
10 | if (
11 | !/^http:\/\/localhost:3030\/?$/i.test(origin) &&
12 | !/^https:\/\/[\w-]+\.diapositiv\.lihbr\.com\/?$/i.test(origin)
13 | ) {
14 | return {}
15 | }
16 |
17 | return {
18 | "access-control-allow-origin": origin,
19 | "vary": "Origin",
20 | }
21 | }
22 |
23 | function upstash(endpoint: string, body?: string): Promise {
24 | const url = new URL(endpoint, process.env.UPSTASH_ENDPOINT!)
25 |
26 | const method = body ? "POST" : "GET"
27 | const headers: Record = body ? { ...JSON_HEADERS } : {}
28 | headers.authorization = `Bearer ${process.env.UPSTASH_TOKEN}`
29 |
30 | return fetch(url.toString(), {
31 | body,
32 | method,
33 | headers,
34 | })
35 | }
36 |
37 | export const handler: Handler = async (event) => {
38 | const CORS_HEADERS = GET_CORS_HEADERS(event.headers.origin)
39 |
40 | if (event.httpMethod.toUpperCase() !== "GET") {
41 | return {
42 | statusCode: 400,
43 | headers: { ...JSON_HEADERS, ...CORS_HEADERS },
44 | body: JSON.stringify({ error: "Bad Request" }),
45 | }
46 | }
47 |
48 | const body = event.queryStringParameters || {}
49 |
50 | const errors: string[] = []
51 | if (!body.id) {
52 | errors.push("`id` is missing in body")
53 | } else if (body.id.length > 8) {
54 | errors.push("`id` cannot be longer than 8 characters")
55 | }
56 |
57 | if (body.vote && body.vote.length > 8) {
58 | errors.push("`vote` cannot be longer than 8 characters")
59 | }
60 |
61 | if (errors.length) {
62 | return {
63 | statusCode: 400,
64 | headers: { ...JSON_HEADERS, ...CORS_HEADERS },
65 | body: JSON.stringify({
66 | error: "Bad Request",
67 | message: errors.join(", "),
68 | }),
69 | }
70 | }
71 |
72 | if (body.vote) {
73 | try {
74 | const res = await upstash(
75 | "/",
76 | JSON.stringify([
77 | "EVAL",
78 | `
79 | local id = KEYS[1]
80 | local vote = ARGV[1]
81 |
82 | local r = redis.call("HINCRBY", id, vote, 1)
83 |
84 | local ttl = redis.call("TTL", id)
85 | if ttl == -1 then
86 | redis.call("EXPIRE", id, 3600)
87 | end
88 |
89 | return r
90 | `,
91 | 1,
92 | body.id,
93 | body.vote,
94 | ]),
95 | )
96 |
97 | if (!res.ok) {
98 | console.error(await res.json())
99 | }
100 | } catch (error) {
101 | console.error(error)
102 | }
103 |
104 | return {
105 | statusCode: 302,
106 | headers: { location: "/talks/poll" },
107 | } as any
108 | }
109 |
110 | const res = await upstash(`./hgetall/${body.id}`)
111 | const json = await res.json()
112 |
113 | if (
114 | !res.ok ||
115 | typeof json !== "object" ||
116 | !json ||
117 | !("result" in json) ||
118 | !Array.isArray(json.result)
119 | ) {
120 | throw new Error(JSON.stringify(json))
121 | }
122 |
123 | const results: Record = {}
124 | for (let i = 0; i < json.result.length; i += 2) {
125 | results[json.result[i]] = Number.parseInt(json.result[i + 1])
126 | }
127 |
128 | return {
129 | statusCode: 200,
130 | headers: { ...JSON_HEADERS, ...CORS_HEADERS },
131 | body: JSON.stringify({
132 | id: body.id,
133 | results,
134 | }),
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/src/functions/preview/index.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from "@netlify/functions"
2 |
3 | import { get, resolve } from "./prismicPreview"
4 |
5 | const JSON_HEADERS = {
6 | "content-type": "application/json",
7 | }
8 |
9 | export const handler: Handler = async (event) => {
10 | if (event.httpMethod.toUpperCase() !== "GET") {
11 | return {
12 | statusCode: 400,
13 | headers: { ...JSON_HEADERS },
14 | body: JSON.stringify({ error: "Bad Request" }),
15 | }
16 | }
17 |
18 | return (await resolve(event)) || (await get(event))
19 | }
20 |
--------------------------------------------------------------------------------
/src/functions/preview/preview.akte.app.ts:
--------------------------------------------------------------------------------
1 | import type { GlobalData } from "../../akte/types"
2 |
3 | import { defineAkteApp } from "akte"
4 |
5 | import { slug } from "../../files/[slug]"
6 | import { fourOFour } from "../../files/404"
7 | import { slug as albumsSlug } from "../../files/albums/[slug]"
8 | import { index as art } from "../../files/art/index"
9 | import { slug as postsSlug } from "../../files/posts/[slug]"
10 | import { preview } from "../../files/preview"
11 | import { slug as privateSlug } from "../../files/private/[slug]"
12 |
13 | export const app = defineAkteApp({
14 | files: [preview, fourOFour, slug, privateSlug, postsSlug, albumsSlug, art],
15 | globalData() {
16 | return {}
17 | },
18 | })
19 |
--------------------------------------------------------------------------------
/src/functions/preview/prismicPreview.ts:
--------------------------------------------------------------------------------
1 | import type { HandlerEvent, HandlerResponse } from "@netlify/functions"
2 |
3 | import process from "node:process"
4 | import * as prismic from "@prismicio/client"
5 |
6 | import { getClient } from "../../akte/prismic"
7 | import { sha256 } from "../../akte/sha256"
8 | import { app } from "./preview.akte.app"
9 |
10 | const HTML_HEADERS = {
11 | "content-type": "text/html; charset=utf-8",
12 | }
13 |
14 | const ROBOTS_HEADERS = {
15 | "x-robots-tag": "noindex, nofollow",
16 | }
17 |
18 | export async function resolve(event: HandlerEvent): Promise {
19 | const { token: previewToken, documentId: documentID } =
20 | event.queryStringParameters ?? {}
21 |
22 | if (!previewToken || !documentID) {
23 | return null
24 | }
25 |
26 | const client = getClient()
27 | let href = await client.resolvePreviewURL({
28 | documentID,
29 | previewToken,
30 | defaultURL: "/",
31 | })
32 |
33 | if (href.startsWith("/private") || href.startsWith("/albums")) {
34 | href = `${href}-${await sha256(href.split("/").pop()!, process.env.PRISMIC_TOKEN!, 7)}`
35 | }
36 |
37 | const previewCookie = {
38 | [new URL(client.endpoint).host.replace(/\.cdn/i, "")]: {
39 | preview: previewToken,
40 | },
41 | }
42 |
43 | return {
44 | statusCode: 302,
45 | headers: {
46 | ...ROBOTS_HEADERS,
47 | "location": `/preview${href}?preview=true`,
48 | "set-cookie": `${prismic.cookie.preview}=${encodeURIComponent(
49 | JSON.stringify(previewCookie),
50 | )}; Path=/${process.env.AWS_LAMBDA_FUNCTION_NAME ? "; Secure" : ""}`,
51 | },
52 | }
53 | }
54 |
55 | export async function get(event: HandlerEvent): Promise {
56 | const cookie = event.headers.cookie ?? ""
57 |
58 | const repository = new URL(getClient().endpoint).host.replace(/\.cdn/i, "")
59 |
60 | const response: HandlerResponse = {
61 | statusCode: 500,
62 | headers: {
63 | ...HTML_HEADERS,
64 | ...ROBOTS_HEADERS,
65 | },
66 | }
67 |
68 | if (cookie.includes(repository)) {
69 | globalThis.document = globalThis.document || {}
70 | globalThis.document.cookie = cookie
71 | app.clearCache(true)
72 |
73 | try {
74 | const file = await app.render(
75 | app.lookup(event.path.replace("/preview", "") ?? "/"),
76 | )
77 | response.statusCode = 200
78 | response.body = file
79 | } catch {
80 | response.statusCode = 404
81 | response.body = await app.render(app.lookup("/404"))
82 | }
83 | } else {
84 | response.statusCode = 202
85 | response.body = await app.render(app.lookup("/preview"))
86 | }
87 |
88 | return response
89 | }
90 |
--------------------------------------------------------------------------------
/src/layouts/base.ts:
--------------------------------------------------------------------------------
1 | import escapeHTML from "escape-html"
2 |
3 | import {
4 | DESCRIPTION_LIMIT,
5 | IS_SERVERLESS,
6 | PAGE_DEFAULT_TITLE,
7 | SITE_ACCENT_COLOR,
8 | SITE_BACKGROUND_COLOR,
9 | SITE_DESCRIPTION,
10 | SITE_LANG,
11 | SITE_META_IMAGE,
12 | SITE_TITLE,
13 | SITE_TITLE_FORMAT,
14 | SITE_URL,
15 | TITLE_LIMIT,
16 | } from "../akte/constants"
17 | import { getClient } from "../akte/prismic"
18 |
19 | const inlineScript = /* html */ ``
20 |
21 | const prismicToolbarScript = IS_SERVERLESS
22 | ? /* html */ ``
28 | : ""
29 |
30 | /**
31 | * Cap a string to a given number of characters correctly
32 | */
33 | function limitLength(string = "", limit = -1): string {
34 | let sanitizedString = string.trim()
35 | if (limit > 0 && sanitizedString.length > limit) {
36 | sanitizedString = sanitizedString.slice(0, limit)
37 | sanitizedString = sanitizedString.slice(
38 | 0,
39 | sanitizedString.lastIndexOf(" "),
40 | )
41 | sanitizedString = `${sanitizedString}...`
42 | }
43 |
44 | return sanitizedString
45 | }
46 |
47 | export type BaseArgs = {
48 | path: string
49 | title?: string | null
50 | description?: string | null
51 | image?: {
52 | openGraph?: string
53 | }
54 | structuredData?: unknown[]
55 | noindex?: boolean
56 | script?: string
57 | }
58 |
59 | export function base(slot: string, args: BaseArgs): string {
60 | const url = `${SITE_URL}${args.path}`.replace(/\/$/, "")
61 |
62 | const title = escapeHTML(
63 | SITE_TITLE_FORMAT.replace(
64 | "%page%",
65 | limitLength(args.title || PAGE_DEFAULT_TITLE, TITLE_LIMIT),
66 | ),
67 | )
68 | const description = escapeHTML(
69 | limitLength(args.description || SITE_DESCRIPTION, DESCRIPTION_LIMIT),
70 | )
71 | const image = {
72 | openGraph: args?.image?.openGraph || SITE_META_IMAGE.openGraph,
73 | }
74 |
75 | const structuredData: unknown[] = [
76 | {
77 | "@context": "http://schema.org",
78 | "@type": "WebSite",
79 | "url": escapeHTML(url),
80 | "name": args.title || PAGE_DEFAULT_TITLE,
81 | "alternateName": SITE_TITLE,
82 | },
83 | ]
84 | if (args.structuredData) {
85 | structuredData.push(...args.structuredData)
86 | }
87 |
88 | const script = (args.script || "/assets/js/_base.ts").replace(
89 | IS_SERVERLESS ? ".ts" : ".js",
90 | ".js",
91 | )
92 |
93 | return /* html */ `
94 |
95 |
96 |
97 |
98 |
99 |
100 | ${title}
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | ${args.noindex ? /* html */ ` ` : ""}
124 |
125 |
126 |
127 |
128 | ${inlineScript}
129 |
130 |
131 | ${slot}
132 | ${prismicToolbarScript}
133 |
134 |
135 | `
136 | }
137 |
--------------------------------------------------------------------------------
/src/layouts/minimal.ts:
--------------------------------------------------------------------------------
1 | import { back } from "../components/back"
2 | import { base, type BaseArgs } from "./base"
3 |
4 | export function minimal(slot: string, args: BaseArgs & { backTo?: string }): string {
5 | return base(
6 | /* html */ `${back({ to: args.backTo })}
7 | ${slot}
8 | ${back({ to: args.backTo, withPreferences: true, class: "mb-16" })}`,
9 | args,
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/layouts/page.ts:
--------------------------------------------------------------------------------
1 | import { back } from "../components/back"
2 | import { footer } from "../components/footer"
3 |
4 | import { base, type BaseArgs } from "./base"
5 |
6 | export function page(slot: string, args: BaseArgs & { backTo?: string }): string {
7 | return base(
8 | /* html */ `${back({ to: args.backTo })}
9 | ${slot}
10 | ${back({ to: args.backTo, withPreferences: true })}
11 | ${footer()}`,
12 | args,
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/src/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/src/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/src/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #e84311
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/favicon-16x16.png
--------------------------------------------------------------------------------
/src/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/favicon-32x32.png
--------------------------------------------------------------------------------
/src/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/favicon.ico
--------------------------------------------------------------------------------
/src/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/icon.png
--------------------------------------------------------------------------------
/src/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/mstile-150x150.png
--------------------------------------------------------------------------------
/src/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 |
3 | Allow: /
4 |
5 | Disallow: /api/
6 | Disallow: /.netlify/
7 | Disallow: /admin
8 | Disallow: /preview
9 | Disallow: /404
10 | Disallow: /talks/poll
11 | Disallow: /private/
12 |
13 | Sitemap: https://lihbr.com/sitemap.xml
14 |
--------------------------------------------------------------------------------
/src/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.14, written by Peter Selinger 2001-2017
9 |
10 |
12 |
31 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lihbr",
3 | "short_name": "lihbr",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#e84311",
17 | "background_color": "#fffefe",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | const process = require("node:process")
2 | const { farben, alpha } = require("@lihbr/farben")
3 |
4 | const content = ["./src/**/*.ts"]
5 |
6 | // Vite does not like it when we watch the `render` folder in development
7 | if (process.env.NODE_ENV === "production") {
8 | content.push("./src/.akte/render/**/*.html")
9 | } else {
10 | content.push("./src/.akte/data/**/*.data")
11 | }
12 |
13 | /** @type {import('tailwindcss').Config} */
14 | module.exports = {
15 | prefix: "",
16 | important: false,
17 | separator: ":",
18 | content,
19 | darkMode: "class",
20 | theme: {
21 | fontFamily: {
22 | sans: [
23 | "Graphit",
24 | "\"Graphit CLS\"",
25 | "Roboto",
26 | "-apple-system",
27 | "BlinkMacSystemFont",
28 | "\"Segoe UI\"",
29 | "Helvetica",
30 | "Arial",
31 | "sans-serif",
32 | "\"Apple Color Emoji\"",
33 | "\"Segoe UI Emoji\"",
34 | ],
35 | mono: [
36 | "Consolas",
37 | "SFMono-Regular",
38 | "\"SF Mono\"",
39 | "Menlo",
40 | "\"Liberation Mono\"",
41 | "monospace",
42 | ],
43 | },
44 | colors: {
45 | transparent: "transparent",
46 | current: "currentColor",
47 | inherit: "inherit",
48 | theme: {
49 | "DEFAULT": "var(--color-theme)",
50 | "o-20": "var(--color-theme-o-20)",
51 | "100": "var(--color-theme-100)",
52 | },
53 | slate: {
54 | "DEFAULT": farben.slate[800], // 800
55 | "o-20": alpha(farben.slate[800], 0.2),
56 | "900": farben.slate[900],
57 | "700": farben.slate[700],
58 | "200": farben.slate[200],
59 | "100": farben.slate[100],
60 | "50": farben.slate[50],
61 | },
62 | cream: {
63 | "DEFAULT": farben.cream[800], // 800
64 | "o-20": alpha(farben.cream[800], 0.2),
65 | "900": farben.cream[900],
66 | "700": farben.cream[700],
67 | "200": farben.cream[200],
68 | "100": farben.cream[100],
69 | "50": farben.cream[50],
70 | },
71 | // o-20 used for tap highlight and inline code only
72 | navy: {
73 | "DEFAULT": farben.navy[400],
74 | "o-20": alpha(farben.navy[400], 0.2),
75 | "100": farben.navy[100],
76 | },
77 | beet: {
78 | "DEFAULT": farben.beet[400],
79 | "o-20": alpha(farben.beet[400], 0.2),
80 | "100": farben.beet[100],
81 | },
82 | flamingo: {
83 | "DEFAULT": farben.flamingo[400],
84 | "o-20": alpha(farben.flamingo[400], 0.2),
85 | "100": farben.flamingo[100],
86 | },
87 | ochre: {
88 | "DEFAULT": farben.ochre[400],
89 | "o-20": alpha(farben.ochre[400], 0.2),
90 | "100": farben.ochre[100],
91 | },
92 | butter: {
93 | "DEFAULT": farben.butter[400],
94 | "o-20": alpha(farben.butter[400], 0.2),
95 | "100": farben.butter[100],
96 | },
97 | mantis: {
98 | "DEFAULT": farben.mantis[400],
99 | "o-20": alpha(farben.mantis[400], 0.2),
100 | "100": farben.mantis[100],
101 | },
102 | },
103 | extend: {
104 | opacity: {
105 | inherit: "inherit",
106 | },
107 | spacing: {
108 | inherit: "inherit",
109 | },
110 | minWidth: {
111 | inherit: "inherit",
112 | },
113 | maxWidth: {
114 | inherit: "inherit",
115 | },
116 | minHeight: {
117 | inherit: "inherit",
118 | },
119 | maxHeight: {
120 | inherit: "inherit",
121 | },
122 | lineHeight: {
123 | 0: 0,
124 | },
125 | transitionDuration: {
126 | 0: "0ms",
127 | },
128 | },
129 | },
130 | plugins: [
131 | ({ addBase, addVariant, theme }) => {
132 | addBase({
133 | "strong": { fontWeight: theme("fontWeight.medium") },
134 | "small": { fontSize: "inherit" },
135 | "label, input, textarea, select": {
136 | display: "block",
137 | fontWeight: "inherit",
138 | fontStyle: "inherit",
139 | },
140 | })
141 |
142 | addVariant("hocus", ["&:hover", "&:focus"])
143 | addVariant("current", "&[aria-current=\"page\"]")
144 | addVariant("left", "html.left &")
145 | addVariant("center", "html.center &")
146 | addVariant("right", "html.right &")
147 | addVariant("open", "details[open] > summary &")
148 | },
149 | ],
150 | }
151 |
--------------------------------------------------------------------------------
/test/__testutils__/readAllFiles.ts:
--------------------------------------------------------------------------------
1 | import type { Buffer } from "node:buffer"
2 | import { readFile } from "node:fs/promises"
3 | import { resolve } from "node:path"
4 |
5 | /**
6 | * Bulk version of `readFile`
7 | *
8 | * @param paths - Paths to files
9 | * @param cwd - Current working directory
10 | *
11 | * @returns Read files as buffer array
12 | */
13 | export function readAllFiles(paths: string[], cwd = ""): Promise<{ path: string, content: Buffer }[]> {
14 | return Promise.all(
15 | paths.map(async (path) => {
16 | return {
17 | path,
18 | content: await readFile(resolve(cwd, path)),
19 | }
20 | }),
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/test/buildIntegrity.test.ts:
--------------------------------------------------------------------------------
1 | import { existsSync } from "node:fs"
2 | import { resolve } from "node:path"
3 |
4 | import { globbySync } from "globby"
5 | import { expect, it } from "vitest"
6 |
7 | import { readAllFiles } from "./__testutils__/readAllFiles"
8 |
9 | // Paths
10 | const akteOutputPath = resolve(__dirname, "../src/.akte/render")
11 | const akteAssetsPath = resolve(__dirname, "../src/assets")
12 |
13 | const viteOutputPath = resolve(__dirname, "../dist")
14 | const viteAssetsPath = resolve(__dirname, "../dist/assets")
15 |
16 | // Globs
17 | const aktePagesGlob = globbySync(["**/*.html"], { cwd: akteOutputPath })
18 | const vitePagesGlob = globbySync(["**/*.html"], { cwd: viteOutputPath })
19 |
20 | it("builds exist", () => {
21 | expect(existsSync(akteOutputPath)).toBe(true)
22 | expect(existsSync(viteOutputPath)).toBe(true)
23 | })
24 |
25 | it("builds output same pages", () => {
26 | expect(aktePagesGlob.length).toBeGreaterThan(0)
27 | expect(vitePagesGlob.length).toBe(aktePagesGlob.length)
28 | })
29 |
30 | it("builds output canonical links", async () => {
31 | const extractCanonicalFromPage = (content: string): string | undefined => {
32 | return content.match(
33 | // eslint-disable-next-line regexp/no-escape-backspace, regexp/no-potentially-useless-backreference
34 | / ['"\b])?canonical\k href=(?['"\b])?(?[/\w.:-]+)\k/,
35 | )?.groups?.href
36 | }
37 |
38 | const vitePages = (await readAllFiles(vitePagesGlob, viteOutputPath)).map(
39 | ({ content }) => content.toString(),
40 | )
41 |
42 | const canonicals = vitePages
43 | .map(extractCanonicalFromPage)
44 | .filter(Boolean)
45 | .filter((href, index, arr) => arr.indexOf(href) === index)
46 | .sort()
47 |
48 | expect(canonicals.length).toBeGreaterThan(0)
49 | expect(canonicals.length).toBe(vitePagesGlob.length)
50 | })
51 |
52 | it("builds have no undefined alt attributes", async () => {
53 | const hasUndefinedAlts = (path: string, content: string): string | undefined => {
54 | if (
55 | content.includes("alt=\"undefined\"") ||
56 | content.includes("alt='undefined'") ||
57 | content.includes("alt=undefined")
58 | ) {
59 | return path
60 | }
61 | }
62 |
63 | const aktePages = (await readAllFiles(aktePagesGlob, akteOutputPath)).map(
64 | ({ path, content }) => [path, content.toString()],
65 | )
66 |
67 | const akteUndefinedAlts = aktePages.map(([path, content]) => hasUndefinedAlts(path, content)).filter(Boolean)
68 |
69 | expect(akteUndefinedAlts).toStrictEqual([])
70 | })
71 |
72 | it("builds reference same script modules", async () => {
73 | /**
74 | * Extracts sources of script modules from an HTML string
75 | */
76 | const extractSourcesFromPage = (content: string, ignore?: string[]): string[] => {
77 | const matches: string[] = []
78 |
79 | /** @see regex101 @link{https://regex101.com/r/fR5vWO/1} */
80 | const regex =
81 | // eslint-disable-next-line regexp/no-escape-backspace, regexp/no-potentially-useless-backreference
82 | /