Add RSS generation (#123)

This commit is contained in:
Drew Powers 2021-04-23 10:44:41 -06:00 committed by GitHub
parent 5eb232501f
commit 510e7920d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 417 additions and 35 deletions

View file

@ -50,18 +50,18 @@ export default {
/** Set this to "preact" or "react" to determine what *.jsx files should load */
'.jsx': 'react',
},
/** Your public domain, e.g.: https://my-site.dev/ */
site: '',
/** Options specific to `astro build` */
buildOptions: {
/** Your public domain, e.g.: https://my-site.dev/. Used to generate sitemaps and canonical URLs. */
site: '',
/** Generate sitemap (set to "false" to disable) */
sitemap: true,
},
/** Options for the development server run with `astro dev`. */
devOptions: {
/** The port to run the dev server on. */
port: 3000
}
port: 3000,
},
};
```
@ -166,7 +166,7 @@ const localData = Astro.fetchContent('../post/*.md');
### 🗺️ Sitemap
Astro will automatically create a `/sitemap.xml` for you for SEO! Be sure to set the `site` URL in your [Astro config][config] so the URLs can be generated properly.
Astro will automatically create a `/sitemap.xml` for you for SEO! Be sure to set `buildOptions.site` in your [Astro config][config] so the URLs can be generated properly.
⚠️ Note that Astro wont inject this into your HTML for you! Youll have to add the tag yourself in your `<head>` on all pages that need it:
@ -181,7 +181,7 @@ Astro will automatically create a `/sitemap.xml` for you for SEO! Be sure to set
### 🍱 Collections (beta)
[Fetching data is easy in Astro](#-fetching-data). But what if you wanted to make a paginated blog? What if you wanted an easy way to sort data, or filter, say, by a given tag? When you need something a little more powerful than simple data fetching, Astros Collections API may be what you need.
[Fetching data is easy in Astro](#-fetching-data). But what if you wanted to make a paginated blog? What if you wanted an easy way to sort data, or filter data based on part of the URL? Or generate an RSS 2.0 feed? When you need something a little more powerful than simple data fetching, Astros Collections API may be what you need.
👉 [**Collections API**][docs-collections]

View file

@ -88,10 +88,57 @@ When using the [Collections API][docs-collections], `createCollection()` is an a
| `pageSize` | `number` | Specify number of items per page (default: `25`). |
| `routes` | `params[]` | **Required for URL Params.** Return an array of all possible URL `param` values in `{ name: value }` form. |
| `permalink` | `({ params }) => string` | **Required for URL Params.** Given a `param` object of `{ name: value }`, generate the final URL.\* |
| `rss` | [RSS][rss] | Optional: generate an RSS 2.0 feed from this collection ([docs][rss]). |
_\* Note: dont create confusing URLs with `permalink`, e.g. rearranging params conditionally based on their values._
⚠️ `createCollection()` executes in its own isolated scope before page loads. Therefore you cant reference anything from its parent scope. If you need to load data you may fetch or use async `import()`s within the function body for anything you need (thats why its `async`—to give you this ability). If it wasnt isolated, then `collection` would be undefined! Therefore, duplicating imports between `createCollection()` and your Astro component is OK.
#### 📡 RSS Feed
You can optionally generate an RSS 2.0 feed from `createCollection()` by adding an `rss` option. Here are all the options:
```jsx
export async function createCollection() {
return {
async data({ params }) {
// load data
},
pageSize: 25,
rss: {
title: 'My RSS Feed',
description: 'Description of the feed',
/** (optional) add xmlns:* properties to root element */
xmlns: {
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
content: 'http://purl.org/rss/1.0/modules/content/',
},
/** (optional) add arbitrary XML to <channel> */
customData: `<language>en-us</language>
<itunes:author>The Sunset Explorers</itunes:author>`,
/** Format each item from things returned in data() */
item: (item) => ({
title: item.title,
description: item.description,
pubDate: item.pubDate,
/** (optional) add arbitrary XML to each <item> */
customData: `<itunes:episodeType>${item.type}</itunes:episodeType>
<itunes:duration>${item.duration}</itunes:duration>
<itunes:explicit>${item.explicit || false}</itunes:explicit>`,
}),
},
};
}
```
Astro will generate an RSS 2.0 feed at `/feed/[collection].xml` (for example, `/astro/pages/$podcast.xml` would generate `/feed/podcast.xml`).
⚠️ Even though Astro will create the RSS feed for you, youll still need to add `<link>` tags manually in your `<head>` HTML:
```html
<link rel="alternate" type="application/rss+xml" title="My RSS Feed" href="/feed/podcast.xml" />
```
[config]: ../README.md#%EF%B8%8F-configuration
[docs-collections]: ./collections.md
[rss]: #-rss-feed

View file

@ -2,7 +2,7 @@
## ❓ What are Collections?
[Fetching data is easy in Astro][docs-data]. But what if you wanted to make a paginated blog? What if you wanted an easy way to sort data, or filter, say, by a given tag? When you need something a little more powerful than simple data fetching, Astros Collections API may be what you need.
[Fetching data is easy in Astro][docs-data]. But what if you wanted to make a paginated blog? What if you wanted an easy way to sort data, or filter data based on part of the URL? Or generate an RSS 2.0 feed? When you need something a little more powerful than simple data fetching, Astros Collections API may be what you need.
An Astro Collection is similar to the general concept of Collections in static site generators like Jekyll, Hugo, Eleventy, etc. Its a general way to load an entire data set. But one big difference between Astro Collections and traditional static site generators is: **Astro lets you seamlessly blend remote API data and local files in a JAMstack-friendly way.** To see how, this guide will walk through a few examples. If youd like, you can reference the [blog example project][example-blog] to see the finished code in context.
@ -187,6 +187,7 @@ These are still paginated, too! But since there are other conditions applied, th
- [Fetching data in Astro][docs-data]
- API Reference: [collection][collection-api]
- API Reference: [createCollection()][create-collection-api]
- API Reference: [Creating an RSS feed][create-collection-api]
[docs-data]: ../README.md#-fetching-data
[collection-api]: ./api.md#collection

View file

@ -18,7 +18,18 @@ export async function createCollection() {
allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
return allPosts;
},
pageSize: 3
pageSize: 3,
rss: {
title: 'Muppet Blog',
description: 'An example blog on Astro',
customData: `<language>en-us</language>`,
item: (item) => ({
title: item.title,
description: item.description,
link: item.url,
pubDate: item.date,
}),
}
};
}
---

33
package-lock.json generated
View file

@ -1346,6 +1346,22 @@
"untildify": "^2.0.0"
}
},
"del": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/del/-/del-6.0.0.tgz",
"integrity": "sha512-1shh9DQ23L16oXSZKB2JxpL7iMy2E0S9d517ptA1P8iw0alkPtQcrKH7ru31rYtKwF499HkTu+DRzq3TCKDFRQ==",
"dev": true,
"requires": {
"globby": "^11.0.1",
"graceful-fs": "^4.2.4",
"is-glob": "^4.0.1",
"is-path-cwd": "^2.2.0",
"is-path-inside": "^3.0.2",
"p-map": "^4.0.0",
"rimraf": "^3.0.2",
"slash": "^3.0.0"
}
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -1849,6 +1865,11 @@
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
"dev": true
},
"fast-xml-parser": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.19.0.tgz",
"integrity": "sha512-4pXwmBplsCPv8FOY1WRakF970TjNGnGnfbOnLqjlYvMiF1SR3yOHyxMR/YCXpPTOspNF5gwudqktIP4VsWkvBg=="
},
"fastq": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz",
@ -2473,6 +2494,18 @@
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
"is-path-cwd": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz",
"integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==",
"dev": true
},
"is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
"dev": true
},
"is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",

View file

@ -46,6 +46,7 @@
"es-module-lexer": "^0.4.1",
"esbuild": "^0.10.1",
"estree-walker": "^3.0.0",
"fast-xml-parser": "^3.19.0",
"fdir": "^5.0.0",
"find-up": "^5.0.0",
"github-slugger": "^1.3.0",
@ -94,6 +95,7 @@
"@typescript-eslint/eslint-plugin": "^4.18.0",
"@typescript-eslint/parser": "^4.18.0",
"concurrently": "^6.0.0",
"del": "^6.0.0",
"domhandler": "^4.1.0",
"eslint": "^7.22.0",
"eslint-config-prettier": "^8.1.0",

View file

@ -14,13 +14,16 @@ export interface AstroConfig {
astroRoot: URL;
public: URL;
extensions?: Record<string, ValidExtensionPlugins>;
/** Public URL base (e.g. 'https://mysite.com'). Used in generating sitemaps and canonical URLs. */
site?: string;
/** Generate a sitemap? */
/** Options specific to `astro build` */
buildOptions: {
/** Your public domain, e.g.: https://my-site.dev/. Used to generate sitemaps and canonical URLs. */
site?: string;
/** Generate sitemap (set to "false" to disable) */
sitemap: boolean;
};
/** Options for the development server run with `astro dev`. */
devOptions: {
/** The port to run the dev server on. */
port: number;
projectRoot?: string;
};
@ -34,7 +37,7 @@ export type AstroUserConfig = Omit<AstroConfig, 'buildOptions' | 'devOptions'> &
port?: number;
projectRoot?: string;
};
}
};
export interface JsxItem {
name: string;
@ -67,6 +70,34 @@ export interface CreateCollection<T = any> {
permalink?: ({ params }: { params: Params }) => string;
/** page size */
pageSize?: number;
/** Generate RSS feed from data() */
rss?: CollectionRSS<T>;
}
export interface CollectionRSS<T = any> {
/** (required) Title of the RSS Feed */
title: string;
/** (required) Description of the RSS Feed */
description: string;
/** Specify arbitrary metadata on opening <xml> tag */
xmlns?: Record<string, string>;
/** Specify custom data in opening of file */
customData?: string;
/** Return data about each item */
item: (
item: T
) => {
/** (required) Title of item */
title: string;
/** (required) Link to item */
link: string;
/** Publication date of item */
pubDate?: Date;
/** Item description */
description?: string;
/** Append some other XML-valid data to this item */
customData?: string;
};
}
export interface CollectionResult<T = any> {

View file

@ -10,8 +10,10 @@ import { fdir } from 'fdir';
import { defaultLogDestination, error, info } from './logger.js';
import { createRuntime } from './runtime.js';
import { bundle, collectDynamicImports } from './build/bundle.js';
import { generateRSS } from './build/rss.js';
import { generateSitemap } from './build/sitemap.js';
import { collectStatics } from './build/static.js';
import { canonicalURL } from './build/util.js';
const { mkdir, readFile, writeFile } = fsPromises;
@ -20,12 +22,14 @@ interface PageBuildOptions {
dist: URL;
filepath: URL;
runtime: AstroRuntime;
site?: string;
sitemap: boolean;
statics: Set<string>;
}
interface PageResult {
canonicalURLs: string[];
rss?: string;
statusCode: number;
}
@ -78,7 +82,7 @@ function getPageType(filepath: URL): 'collection' | 'static' {
}
/** Build collection */
async function buildCollectionPage({ astroRoot, dist, filepath, runtime, statics }: PageBuildOptions): Promise<PageResult> {
async function buildCollectionPage({ astroRoot, dist, filepath, runtime, site, statics }: PageBuildOptions): Promise<PageResult> {
const rel = path.relative(fileURLToPath(astroRoot) + '/pages', fileURLToPath(filepath)); // pages/index.astro
const pagePath = `/${rel.replace(/\$([^.]+)\.astro$/, '$1')}`;
const builtURLs = new Set<string>(); // !important: internal cache that prevents building the same URLs
@ -97,12 +101,19 @@ async function buildCollectionPage({ astroRoot, dist, filepath, runtime, statics
}
const result = (await loadCollection(pagePath)) as LoadResult;
if (result.statusCode >= 500) {
throw new Error((result as any).error);
}
if (result.statusCode === 200 && !result.collectionInfo) {
throw new Error(`[${rel}]: Collection page must export createCollection() function`);
}
let rss: string | undefined;
// note: for pages that require params (/tag/:tag), we will get a 404 but will still get back collectionInfo that tell us what the URLs should be
if (result.collectionInfo) {
// build subsequent pages
await Promise.all(
[...result.collectionInfo.additionalURLs].map(async (url) => {
// for the top set of additional URLs, we render every new URL generated
@ -114,11 +125,17 @@ async function buildCollectionPage({ astroRoot, dist, filepath, runtime, statics
}
})
);
if (result.collectionInfo.rss) {
if (!site) throw new Error(`[${rel}] createCollection() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`);
rss = generateRSS({ ...(result.collectionInfo.rss as any), site }, rel.replace(/\$([^.]+)\.astro$/, '$1'));
}
}
return {
canonicalURLs: [...builtURLs].filter((url) => !url.endsWith('/1')), // note: canonical URLs are controlled by the collection, so these are canonical (but exclude "/1" pages as those are duplicates of the index)
statusCode: result.statusCode,
rss,
};
}
@ -186,10 +203,17 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> {
const filepath = new URL(`file://${pathname}`);
const pageType = getPageType(filepath);
const pageOptions: PageBuildOptions = { astroRoot, dist, filepath, runtime, sitemap: astroConfig.buildOptions.sitemap, statics };
const pageOptions: PageBuildOptions = { astroRoot, dist, filepath, runtime, site: astroConfig.buildOptions.site, sitemap: astroConfig.buildOptions.sitemap, statics };
if (pageType === 'collection') {
const { canonicalURLs } = await buildCollectionPage(pageOptions);
const { canonicalURLs, rss } = await buildCollectionPage(pageOptions);
builtURLs.push(...canonicalURLs);
if (rss) {
const basename = path
.relative(fileURLToPath(astroRoot) + '/pages', pathname)
.replace(/^\$/, '')
.replace(/\.astro$/, '');
await writeFilep(new URL(`file://${path.join(fileURLToPath(dist), 'feed', basename + '.xml')}`), rss, 'utf8');
}
} else {
const { canonicalURLs } = await buildStaticPage(pageOptions);
builtURLs.push(...canonicalURLs);
@ -239,18 +263,11 @@ export async function build(astroConfig: AstroConfig): Promise<0 | 1> {
}
// build sitemap
if (astroConfig.buildOptions.sitemap && astroConfig.site) {
const sitemap = generateSitemap(
builtURLs.map((url) => ({
canonicalURL: new URL(
path.extname(url) ? url : url.replace(/\/?$/, '/'), // add trailing slash if theres no extension
astroConfig.site
).href,
}))
);
if (astroConfig.buildOptions.sitemap && astroConfig.buildOptions.site) {
const sitemap = generateSitemap(builtURLs.map((url) => ({ canonicalURL: canonicalURL(url, astroConfig.buildOptions.site) })));
await writeFile(new URL('./sitemap.xml', dist), sitemap, 'utf8');
} else if (astroConfig.buildOptions.sitemap) {
info(logging, 'tip', `Set your "site" in astro.config.mjs to generate a sitemap.xml`);
info(logging, 'tip', `Set "buildOptions.site" in astro.config.mjs to generate a sitemap.xml`);
}
await runtime.shutdown();

68
src/build/rss.ts Normal file
View file

@ -0,0 +1,68 @@
import type { CollectionRSS } from '../@types/astro';
import parser from 'fast-xml-parser';
import { canonicalURL } from './util.js';
/** Validates createCollection.rss */
export function validateRSS(rss: CollectionRSS, filename: string): void {
if (!rss.title) throw new Error(`[${filename}] rss.title required`);
if (!rss.description) throw new Error(`[${filename}] rss.description required`);
if (typeof rss.item !== 'function') throw new Error(`[${filename}] rss.item() function required`);
}
/** Generate RSS 2.0 feed */
export function generateRSS<T>(input: { data: T[]; site: string } & CollectionRSS<T>, filename: string): string {
let xml = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"`;
validateRSS(input as any, filename);
// xmlns
if (input.xmlns) {
for (const [k, v] of Object.entries(input.xmlns)) {
xml += ` xmlns:${k}="${v}"`;
}
}
xml += `>`;
xml += `<channel>`;
// title, description, customData
xml += `<title><![CDATA[${input.title}]]></title>`;
xml += `<description><![CDATA[${input.description}]]></description>`;
xml += `<link>${canonicalURL('/feed/' + filename + '.xml', input.site)}</link>`;
if (typeof input.customData === 'string') xml += input.customData;
// items
if (!Array.isArray(input.data) || !input.data.length) throw new Error(`[${filename}] data() returned no items. Cant generate RSS feed.`);
for (const item of input.data) {
xml += `<item>`;
const result = input.item(item);
// validate
if (typeof result !== 'object') throw new Error(`[${filename}] rss.item() expected to return an object, returned ${typeof result}.`);
if (!result.title) throw new Error(`[${filename}] rss.item() returned object but required "title" is missing.`);
if (!result.link) throw new Error(`[${filename}] rss.item() returned object but required "link" is missing.`);
xml += `<title><![CDATA[${result.title}]]></title>`;
xml += `<link>${canonicalURL(result.link, input.site)}</link>`;
if (result.description) xml += `<description><![CDATA[${result.description}]]></description>`;
if (result.pubDate) {
// note: this should be a Date, but if user provided a string or number, we can work with that, too.
if (typeof result.pubDate === 'number' || typeof result.pubDate === 'string') {
result.pubDate = new Date(result.pubDate);
} else if (result.pubDate instanceof Date === false) {
throw new Error('[${filename}] rss.item().pubDate must be a Date');
}
xml += `<pubDate>${result.pubDate.toUTCString()}</pubDate>`;
}
if (typeof result.customData === 'string') xml += result.customData;
xml += `</item>`;
}
xml += `</channel></rss>`;
// validate users inputs to see if its valid XML
const isValid = parser.validate(xml);
if (isValid !== true) {
// If valid XML, isValid will be `true`. Otherwise, this will be an error object. Throw.
throw new Error(isValid as any);
}
return xml;
}

View file

@ -10,7 +10,7 @@ export function collectStatics(html: string) {
const append = (el: Element, attr: string) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const value: string = $(el).attr(attr)!;
if (value.startsWith('http')) {
if (value.startsWith('http') || $(el).attr('rel') === 'alternate') {
return;
}
statics.add(value);

9
src/build/util.ts Normal file
View file

@ -0,0 +1,9 @@
import path from 'path';
/** Normalize URL to its canonical form */
export function canonicalURL(url: string, base?: string): string {
return new URL(
path.extname(url) ? url : url.replace(/(\/+)?$/, '/'), // add trailing slash if theres no extension
base
).href;
}

View file

@ -1,6 +1,6 @@
import { fileURLToPath } from 'url';
import type { SnowpackDevServer, ServerRuntime as SnowpackServerRuntime, SnowpackConfig } from 'snowpack';
import type { AstroConfig, CollectionResult, CreateCollection, Params, RuntimeMode } from './@types/astro';
import type { AstroConfig, CollectionResult, CollectionRSS, CreateCollection, Params, RuntimeMode } from './@types/astro';
import type { LogOptions } from './logger';
import type { CompileError } from './parser/utils/error.js';
import { debug, info } from './logger.js';
@ -26,7 +26,10 @@ interface RuntimeConfig {
}
// info needed for collection generation
type CollectionInfo = { additionalURLs: Set<string> };
interface CollectionInfo {
additionalURLs: Set<string>;
rss?: { data: any[] & CollectionRSS };
}
type LoadResultSuccess = {
statusCode: 200;
@ -78,6 +81,7 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
}
const snowpackURL = searchResult.location.snowpackURL;
let rss: { data: any[] & CollectionRSS } = {} as any;
try {
const mod = await backendSnowpackRuntime.importModule(snowpackURL);
@ -90,11 +94,11 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
if (mod.exports.createCollection) {
const createCollection: CreateCollection = await mod.exports.createCollection();
for (const key of Object.keys(createCollection)) {
if (key !== 'data' && key !== 'routes' && key !== 'permalink' && key !== 'pageSize') {
if (key !== 'data' && key !== 'routes' && key !== 'permalink' && key !== 'pageSize' && key !== 'rss') {
throw new Error(`[createCollection] unknown option: "${key}"`);
}
}
let { data: loadData, routes, permalink, pageSize } = createCollection;
let { data: loadData, routes, permalink, pageSize, rss: createRSS } = createCollection;
if (!pageSize) pageSize = 25; // cant be 0
let currentParams: Params = {};
@ -116,6 +120,14 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
let data: any[] = await loadData({ params: currentParams });
// handle RSS
if (createRSS) {
rss = {
...createRSS,
data: [...data] as any,
};
}
collection.start = 0;
collection.end = data.length - 1;
collection.total = data.length;
@ -161,7 +173,10 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
return {
statusCode: 301,
location: reqPath + '/1',
collectionInfo: additionalURLs.size ? { additionalURLs } : undefined,
collectionInfo: {
additionalURLs,
rss: rss.data ? rss : undefined,
},
};
}
@ -170,7 +185,10 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
return {
statusCode: 404,
error: new Error('Not Found'),
collectionInfo: additionalURLs.size ? { additionalURLs } : undefined,
collectionInfo: {
additionalURLs,
rss: rss.data ? rss : undefined,
},
};
}
@ -200,7 +218,10 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
statusCode: 200,
contentType: 'text/html; charset=utf-8',
contents: html,
collectionInfo: additionalURLs.size ? { additionalURLs } : undefined,
collectionInfo: {
additionalURLs,
rss: rss.data ? rss : undefined,
},
};
} catch (err) {
if (err.code === 'parse-error') {

27
test/astro-rss.test.js Normal file
View file

@ -0,0 +1,27 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import del from 'del';
import { fileURLToPath } from 'url';
const RSS = suite('RSS Generation');
const snapshot = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title><![CDATA[MF Doomcast]]></title><description><![CDATA[The podcast about the things you find on a picnic, or at a picnic table]]></description><link>https://mysite.dev/feed/episodes.xml</link><language>en-us</language><itunes:author>MF Doom</itunes:author><item><title><![CDATA[Rap Snitch Knishes (feat. Mr. Fantastik)]]></title><link>https://mysite.dev/episode/rap-snitch-knishes/</link><description><![CDATA[Complex named this song the “22nd funniest rap song of all time.”]]></description><pubDate>Tue, 16 Nov 2004 07:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>172</itunes:duration><itunes:explicit>true</itunes:explicit></item><item><title><![CDATA[Fazers]]></title><link>https://mysite.dev/episode/fazers/</link><description><![CDATA[Rhapsody ranked Take Me to Your Leader 17th on its list “Hip-Hops Best Albums of the Decade”]]></description><pubDate>Thu, 03 Jul 2003 06:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>197</itunes:duration><itunes:explicit>true</itunes:explicit></item><item><title><![CDATA[Rhymes Like Dimes (feat. Cucumber Slice)]]></title><link>https://mysite.dev/episode/rhymes-like-dimes/</link><description><![CDATA[Operation: Doomsday has been heralded as an underground classic that established MF Doom's rank within the underground hip-hop scene during the early to mid-2000s.
]]></description><pubDate>Tue, 19 Oct 1999 06:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>259</itunes:duration><itunes:explicit>true</itunes:explicit></item></channel></rss>`;
const cwd = new URL('./fixtures/astro-rss', import.meta.url);
const clear = () => del(path.join(fileURLToPath(cwd), '_site')); // clear _site output
RSS.before(() => clear());
RSS.after(() => clear());
RSS('Generates RSS correctly', async () => {
execSync('node ../../../astro.mjs build', { cwd: fileURLToPath(cwd) });
const rss = await fs.promises.readFile(path.join(fileURLToPath(cwd), '_site', 'feed', 'episodes.xml'), 'utf8');
assert.match(rss, snapshot);
});
RSS.run();

View file

@ -0,0 +1,26 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import del from 'del';
import { fileURLToPath } from 'url';
const Sitemap = suite('Sitemap Generation');
const snapshot = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://mysite.dev/episode/fazers/</loc></url><url><loc>https://mysite.dev/episode/rap-snitch-knishes/</loc></url><url><loc>https://mysite.dev/episode/rhymes-like-dimes/</loc></url><url><loc>https://mysite.dev/episodes/</loc></url></urlset>`;
const cwd = new URL('./fixtures/astro-rss', import.meta.url);
const clear = () => del(path.join(fileURLToPath(cwd), '_site')); // clear _site output
Sitemap.before(() => clear());
Sitemap.after(() => clear());
Sitemap('Generates Sitemap correctly', async () => {
execSync('node ../../../astro.mjs build', { cwd: fileURLToPath(cwd) });
const rss = await fs.promises.readFile(path.join(fileURLToPath(cwd), '_site', 'sitemap.xml'), 'utf8');
assert.match(rss, snapshot);
});
Sitemap.run();

View file

@ -1,3 +1,5 @@
export default {
sitemap: false,
buildOptions: {
sitemap: false,
},
};

View file

@ -2,5 +2,7 @@ export default {
extensions: {
'.jsx': 'preact',
},
sitemap: false,
buildOptions: {
sitemap: false,
},
};

View file

@ -0,0 +1,5 @@
export default {
buildOptions: {
site: 'https://mysite.dev',
},
};

View file

@ -0,0 +1,40 @@
---
export let collection;
export async function createCollection() {
return {
async data() {
const episodes = Astro.fetchContent('./episode/*.md');
episodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate));
return episodes;
},
rss: {
title: 'MF Doomcast',
description: 'The podcast about the things you find on a picnic, or at a picnic table',
xmlns: {
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
content: 'http://purl.org/rss/1.0/modules/content/',
},
customData: `<language>en-us</language>` +
`<itunes:author>MF Doom</itunes:author>`,
item: (item) => ({
title: item.title,
link: item.url,
description: item.description,
pubDate: item.pubDate,
customData: `<itunes:episodeType>${item.type}</itunes:episodeType>` +
`<itunes:duration>${item.duration}</itunes:duration>` +
`<itunes:explicit>${item.explicit || false}</itunes:explicit>`,
}),
}
}
}
---
<html>
<head>
<title>Podcast Episodes</title>
<link rel="alternate" type="application/rss+2.0" href="/feed/episodes.xml" />
</head>
<body></body>
</html>

View file

@ -0,0 +1,13 @@
---
title: Fazers
artist: King Geedorah
type: music
duration: 197
pubDate: '2003-07-03 00:00:00'
description: Rhapsody ranked Take Me to Your Leader 17th on its list “Hip-Hops Best Albums of the Decade”
explicit: true
---
# Fazers
Rhapsody ranked Take Me to Your Leader 17th on its list “Hip-Hops Best Albums of the Decade”

View file

@ -0,0 +1,13 @@
---
title: Rap Snitch Knishes (feat. Mr. Fantastik)
artist: MF Doom
type: music
duration: 172
pubDate: '2004-11-16 00:00:00'
description: Complex named this song the “22nd funniest rap song of all time.”
explicit: true
---
# Rap Snitch Knishes (feat. Mr. Fantastik)
Complex named this song the “22nd funniest rap song of all time.”

View file

@ -0,0 +1,14 @@
---
title: Rhymes Like Dimes (feat. Cucumber Slice)
artist: MF Doom
type: music
duration: 259
pubDate: '1999-10-19 00:00:00'
description: |
Operation: Doomsday has been heralded as an underground classic that established MF Doom's rank within the underground hip-hop scene during the early to mid-2000s.
explicit: true
---
# Rhymes Like Dimes (feat. Cucumber Slice)
Operation: Doomsday has been heralded as an underground classic that established MF Doom's rank within the underground hip-hop scene during the early to mid-2000s.