diff --git a/deno.json b/deno.json index 910ec45..5b6e845 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,5 @@ { "tasks": { - "test": "deno test --allow-env --allow-write --allow-read" + "test": "deno test --allow-env --allow-write --allow-read --allow-run --allow-net" } } diff --git a/deno.lock b/deno.lock index 27766d9..1b18944 100644 --- a/deno.lock +++ b/deno.lock @@ -1,6 +1,7 @@ { "version": "2", "remote": { + "https://deno.land/std@0.193.0/streams/text_line_stream.ts": "0f2c4b33a5fdb2476f2e060974cba1347cefe99a4af33c28a57524b1a34750fa", "https://deno.land/std@0.197.0/_util/diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", "https://deno.land/std@0.197.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", "https://deno.land/std@0.197.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", diff --git a/src/plugin.ts b/src/plugin.ts index b88eb26..158c3bd 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -2,40 +2,45 @@ import { SitemapContext } from "./sitemap.ts"; import { Manifest, Plugin, RouteProps } from "./types.ts"; interface PluginOptions { - include?: Array - exclude?: Array + include?: Array; + exclude?: Array; } -export const freshSEOPlugin = (manifest: Manifest, opts: PluginOptions = {}): Plugin => { - return { - name: "fresh-seo", - routes: [ - { - path: "/sitemap.xml", - handler: (req) => { - const sitemap = new SitemapContext(req.url.replace("/sitemap.xml", ""), manifest); +export const freshSEOPlugin = ( + manifest: Manifest, + opts: PluginOptions = {} +): Plugin => { + return { + name: "fresh-seo", + routes: [ + { + path: "/sitemap.xml", + handler: (req) => { + const sitemap = new SitemapContext( + req.url.replace("/sitemap.xml", ""), + manifest + ); - if (opts.include) { - opts.include.forEach((route) => { - if (typeof route === "string") { - sitemap.add(route); - return; - } + if (opts.include) { + opts.include.forEach((route) => { + if (typeof route === "string") { + sitemap.add(route); + return; + } - sitemap.add(route.path, route.options); - }) - } + sitemap.add(route.path, route.options); + }); + } - if (opts.exclude) { - opts.exclude.forEach((path) => { - sitemap.remove(path); - }) - } + if (opts.exclude) { + opts.exclude.forEach((path) => { + sitemap.remove(path); + }); + } - - return sitemap.render(); - } - } - ] - } -} + return sitemap.render(); + }, + }, + ], + }; +}; diff --git a/tests/deps.ts b/tests/deps.ts new file mode 100644 index 0000000..8caeaf2 --- /dev/null +++ b/tests/deps.ts @@ -0,0 +1,12 @@ +/// +/// +/// +/// +/// + +export { TextLineStream } from "https://deno.land/std@0.193.0/streams/text_line_stream.ts"; +export { + assert, + assertStringIncludes, + assertThrows, +} from "https://deno.land/std@0.197.0/assert/mod.ts"; diff --git a/tests/fixture/.gitignore b/tests/fixture/.gitignore new file mode 100644 index 0000000..4e06ffc --- /dev/null +++ b/tests/fixture/.gitignore @@ -0,0 +1,6 @@ +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local diff --git a/tests/fixture/README.md b/tests/fixture/README.md new file mode 100644 index 0000000..ec0e33e --- /dev/null +++ b/tests/fixture/README.md @@ -0,0 +1,16 @@ +# Fresh project + +Your new Fresh project is ready to go. You can follow the Fresh "Getting +Started" guide here: https://fresh.deno.dev/docs/getting-started + +### Usage + +Make sure to install Deno: https://deno.land/manual/getting_started/installation + +Then start the project: + +``` +deno task start +``` + +This will watch the project directory and restart as necessary. diff --git a/tests/fixture/components/Button.tsx b/tests/fixture/components/Button.tsx new file mode 100644 index 0000000..f1b80a0 --- /dev/null +++ b/tests/fixture/components/Button.tsx @@ -0,0 +1,12 @@ +import { JSX } from "preact"; +import { IS_BROWSER } from "$fresh/runtime.ts"; + +export function Button(props: JSX.HTMLAttributes) { + return ( + +

{props.count}

+ + + ); +} diff --git a/tests/fixture/main.ts b/tests/fixture/main.ts new file mode 100644 index 0000000..8e60257 --- /dev/null +++ b/tests/fixture/main.ts @@ -0,0 +1,15 @@ +/// +/// +/// +/// +/// + +import "$std/dotenv/load.ts"; + +import { start } from "$fresh/server.ts"; +import manifest from "./fresh.gen.ts"; +import { freshSEOPlugin } from "fresh-seo"; + +await start(manifest, { + plugins: [freshSEOPlugin(manifest)], +}); diff --git a/tests/fixture/options.ts b/tests/fixture/options.ts new file mode 100644 index 0000000..6daf8a1 --- /dev/null +++ b/tests/fixture/options.ts @@ -0,0 +1,5 @@ +import { freshSEOPlugin } from "fresh-seo"; +import manifest from "./fresh.gen.ts"; +export const options = { + plugins: [freshSEOPlugin(manifest)], +}; diff --git a/tests/fixture/routes/_404.tsx b/tests/fixture/routes/_404.tsx new file mode 100644 index 0000000..c8228ab --- /dev/null +++ b/tests/fixture/routes/_404.tsx @@ -0,0 +1,28 @@ + +import { Head } from "$fresh/runtime.ts"; + +export default function Error404() { + return ( + <> + + 404 - Page not found + +
+
+ the fresh logo: a sliced lemon dripping with juice +

404 - Page not found

+

+ The page you were looking for doesn't exist. +

+ Go back home +
+
+ + ); +} diff --git a/tests/fixture/routes/_500.tsx b/tests/fixture/routes/_500.tsx new file mode 100644 index 0000000..413716c --- /dev/null +++ b/tests/fixture/routes/_500.tsx @@ -0,0 +1,27 @@ +import { Head } from "$fresh/runtime.ts"; + +export default function Error500() { + return ( + <> + + 500 - Internal Server Error + +
+
+ the fresh logo: a sliced lemon dripping with juice +

500 - Internal Server Error

+

Sorry! Something went wrong.

+ + Go back home + +
+
+ + ); +} diff --git a/tests/fixture/routes/_app.tsx b/tests/fixture/routes/_app.tsx new file mode 100644 index 0000000..13789b6 --- /dev/null +++ b/tests/fixture/routes/_app.tsx @@ -0,0 +1,13 @@ +import { AppProps } from "$fresh/server.ts"; +import { Head } from "$fresh/runtime.ts"; + +export default function App({ Component }: AppProps) { + return ( + <> + + + + + + ); +} diff --git a/tests/fixture/routes/_middleware.tsx b/tests/fixture/routes/_middleware.tsx new file mode 100644 index 0000000..5a84b5a --- /dev/null +++ b/tests/fixture/routes/_middleware.tsx @@ -0,0 +1,15 @@ +import { MiddlewareHandlerContext } from "$fresh/server.ts"; + +export const handler = [timing]; + +async function timing( + _req: Request, + ctx: MiddlewareHandlerContext +): Promise { + const start = performance.now(); + const res = await ctx.next(); + const end = performance.now(); + const dur = (end - start).toFixed(1); + res.headers.set("Server-Timing", `handler;dur=${dur}`); + return res; +} diff --git a/tests/fixture/routes/about.tsx b/tests/fixture/routes/about.tsx new file mode 100644 index 0000000..562f6a8 --- /dev/null +++ b/tests/fixture/routes/about.tsx @@ -0,0 +1,3 @@ +export default function About() { + return
This is the about page
; +} diff --git a/tests/fixture/routes/api/joke.ts b/tests/fixture/routes/api/joke.ts new file mode 100644 index 0000000..a3f4243 --- /dev/null +++ b/tests/fixture/routes/api/joke.ts @@ -0,0 +1,21 @@ +import { HandlerContext } from "$fresh/server.ts"; + +// Jokes courtesy of https://punsandoneliners.com/randomness/programmer-jokes/ +const JOKES = [ + "Why do Java developers often wear glasses? They can't C#.", + "A SQL query walks into a bar, goes up to two tables and says “can I join you?”", + "Wasn't hard to crack Forrest Gump's password. 1forrest1.", + "I love pressing the F5 key. It's refreshing.", + "Called IT support and a chap from Australia came to fix my network connection. I asked “Do you come from a LAN down under?”", + "There are 10 types of people in the world. Those who understand binary and those who don't.", + "Why are assembly programmers often wet? They work below C level.", + "My favourite computer based band is the Black IPs.", + "What programme do you use to predict the music tastes of former US presidential candidates? An Al Gore Rhythm.", + "An SEO expert walked into a bar, pub, inn, tavern, hostelry, public house.", +]; + +export const handler = (_req: Request, _ctx: HandlerContext): Response => { + const randomIndex = Math.floor(Math.random() * JOKES.length); + const body = JOKES[randomIndex]; + return new Response(body); +}; diff --git a/tests/fixture/routes/greet/[name].tsx b/tests/fixture/routes/greet/[name].tsx new file mode 100644 index 0000000..9c06827 --- /dev/null +++ b/tests/fixture/routes/greet/[name].tsx @@ -0,0 +1,5 @@ +import { PageProps } from "$fresh/server.ts"; + +export default function Greet(props: PageProps) { + return
Hello {props.params.name}
; +} diff --git a/tests/fixture/routes/index.tsx b/tests/fixture/routes/index.tsx new file mode 100644 index 0000000..23b87e8 --- /dev/null +++ b/tests/fixture/routes/index.tsx @@ -0,0 +1,31 @@ +import { Head } from "$fresh/runtime.ts"; +import { useSignal } from "@preact/signals"; +import Counter from "../islands/Counter.tsx"; + +export default function Home() { + const count = useSignal(3); + return ( + <> + + fixture + +
+
+ the fresh logo: a sliced lemon dripping with juice +

Welcome to fresh

+

+ Try updating this message in the + ./routes/index.tsx file, and refresh. +

+ +
+
+ + ); +} diff --git a/tests/fixture/routes/sitemap.xml.tsx b/tests/fixture/routes/sitemap.xml.tsx new file mode 100644 index 0000000..314262c --- /dev/null +++ b/tests/fixture/routes/sitemap.xml.tsx @@ -0,0 +1,3 @@ +export default function Sitemap() { + return
this is the sitemap route
; +} diff --git a/tests/fixture/static/favicon.ico b/tests/fixture/static/favicon.ico new file mode 100644 index 0000000..1cfaaa2 Binary files /dev/null and b/tests/fixture/static/favicon.ico differ diff --git a/tests/fixture/static/logo.svg b/tests/fixture/static/logo.svg new file mode 100644 index 0000000..ef2fbe4 --- /dev/null +++ b/tests/fixture/static/logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/tests/fixture/static/styles.css b/tests/fixture/static/styles.css new file mode 100644 index 0000000..c644131 --- /dev/null +++ b/tests/fixture/static/styles.css @@ -0,0 +1,126 @@ + +*, +*::before, +*::after { + box-sizing: boder-box; +} +* { + margin: 0; +} +button { + color: inherit; +} +button, [role="button"] { + cursor: pointer; +} +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + font-size: 1em; +} +img, +svg { + display: block; +} +img, +video { + max-width: 100%; + height: auto; +} + +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} +.transition-colors { + transition-property: background-color, border-color, color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} +.my-6 { + margin-bottom: 1.5rem; + margin-top: 1.5rem; +} +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} +.mx-2 { + margin-left: 0.5rem; + margin-right: 0.5rem; +} +.my-4 { + margin-bottom: 1rem; + margin-top: 1rem; +} +.mx-auto { + margin-left: auto; + margin-right: auto; +} +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} +.py-8 { + padding-bottom: 2rem; + padding-top: 2rem; +} +.bg-\[\#86efac\] { + background-color: #86efac; +} +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} +.py-6 { + padding-bottom: 1.5rem; + padding-top: 1.5rem; +} +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} +.py-1 { + padding-bottom: 0.25rem; + padding-top: 0.25rem; +} +.border-gray-500 { + border-color: #6b7280; +} +.bg-white { + background-color: #fff; +} +.flex { + display: flex; +} +.gap-8 { + grid-gap: 2rem; + gap: 2rem; +} +.font-bold { + font-weight: 700; +} +.max-w-screen-md { + max-width: 768px; +} +.flex-col { + flex-direction: column; +} +.items-center { + align-items: center; +} +.justify-center { + justify-content: center; +} +.border-2 { + border-width: 2px; +} +.rounded { + border-radius: 0.25rem; +} +.hover:bg-gray-200:hover { + background-color: #e5e7eb; +} diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts new file mode 100644 index 0000000..0e935c4 --- /dev/null +++ b/tests/plugin.test.ts @@ -0,0 +1,50 @@ +import { + fetchSitemapAsText, + formatYearMonthDate, + withFresh, +} from "./test_utils.ts"; +import { assert, assertStringIncludes, assertThrows } from "./deps.ts"; + +Deno.test("sitemap generation works", async (t) => { + await withFresh("./tests/fixture/dev.ts", async (address) => { + const sitemap = await fetchSitemapAsText(`${address}/sitemap.xml`); + const today = formatYearMonthDate(new Date()); + await t.step("should have sitemap.xml route", () => { + assertStringIncludes(sitemap, ''); + assertStringIncludes( + sitemap, + '' + ); + }); + + await t.step("static routes should present", () => { + // index route should present + assertStringIncludes(sitemap, `${address}/`); + assertStringIncludes(sitemap, `${today}`); + assertStringIncludes(sitemap, `daily`); + assertStringIncludes(sitemap, `0.8`); + assertStringIncludes(sitemap, ""); + + // about route should present + assertStringIncludes(sitemap, `${address}/about`); + assertStringIncludes(sitemap, `${today}`); + assertStringIncludes(sitemap, `daily`); + assertStringIncludes(sitemap, `0.8`); + assertStringIncludes(sitemap, ""); + }); + + await t.step("Special routes should not present", () => { + assert(!sitemap.includes(`https://${address}/_404`)); + assert(!sitemap.includes(`https://${address}/_middleware`)); + assert(!sitemap.includes(`https://${address}/_500`)); + }); + + await t.step("Dynamic routes should not present", () => { + assert(!sitemap.includes(`https://${address}/greet/[name]`)); + }); + + await t.step("sitemap.xml route should not present", () => { + assert(!sitemap.includes(`https://${address}/sitemap.xml`)); + }); + }); +}); diff --git a/tests/test_utils.ts b/tests/test_utils.ts new file mode 100644 index 0000000..9f7db6f --- /dev/null +++ b/tests/test_utils.ts @@ -0,0 +1,74 @@ +import { TextLineStream } from "./deps.ts"; + +export async function startFreshServer(options: Deno.CommandOptions) { + const { serverProcess, lines, address } = await spawnServer(options); + + if (!address) { + throw new Error("Server didn't start up"); + } + + return { serverProcess, lines, address }; +} + +export async function withFresh( + name: string, + fn: (address: string) => Promise +) { + const { lines, serverProcess, address } = await startFreshServer({ + args: ["run", "-A", name], + }); + + try { + await fn(address); + } finally { + await lines.cancel(); + + serverProcess.kill("SIGTERM"); + + // Wait until the process exits + await serverProcess.status; + } +} + +async function spawnServer(options: Deno.CommandOptions, expectErrors = false) { + const serverProcess = new Deno.Command(Deno.execPath(), { + ...options, + stdin: "null", + stdout: "piped", + stderr: expectErrors ? "piped" : "inherit", + }).spawn(); + + const decoder = new TextDecoderStream(); + const lines: ReadableStream = serverProcess.stdout + .pipeThrough(decoder) + .pipeThrough(new TextLineStream(), { + preventCancel: true, + }); + + let address = ""; + for await (const line of lines) { + const match = line.match(/https?:\/\/localhost:\d+/g); + if (match) { + address = match[0]; + break; + } + } + + return { serverProcess, lines, address }; +} + +/** + * Format date to YYYY-MM-DD + * @param date + * @returns formatted date + */ +export function formatYearMonthDate(date: Date) { + return `${date.getFullYear()}-${("00" + (date.getMonth() + 1)).slice(-2)}-${( + "00" + date.getDate() + ).slice(-2)}`; +} + +export async function fetchSitemapAsText(address: string) { + const resp = await fetch(address); + return resp.text(); +}