Help us shape the future of Go! Take the 2026 Go Developer Survey

publishing

package
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 23 Imported by: 0

README

Publishing

News, blogs and other streams of dated posts, with categories, an image, Markdown text, drafts, previews and scheduled publishing.

Use one instance per stream: a site can have news and blog side by side, each with its own posts, categories, admin section and templates.

Add it to the site

app.Use(
    publishing.Module("news", "News"),
    publishing.Module("blog", "Blog"),
)

The key (news) is the instance's name in URLs and templates and must be unique on the site. The title (News) is what editors see in the admin navigation and the page editor.

Place it on a page

In the admin, open a page and choose the instance under Shows on this page. The page then serves the posts at and below its own address; with the page at /news (after the language prefix, such as /en/news):

Address Template Shows
/news news/list.gohtml the published posts, newest first
/news/category/<slug> news/list.gohtml the posts in one category
/news/<slug> news/post.gohtml one post

Templates are looked up by instance key first, then by module type, so publishing/post.gohtml serves every Publishing instance that has no design of its own. Generate starting points with every field of the data:

task templates -- news

A minimal list:

{{/* @dot github.com/donseba/go-webthings/modules/publishing.ListView */}}
<h1>{{.Title}}</h1>

{{range .Categories}}
    <a href="{{.URL}}"{{if .Active}} aria-current="page"{{end}}>{{.Title}}</a>
{{end}}

{{range .Posts}}
    <article>
        <h2><a href="{{.URL}}">{{.Title}}</a></h2>
        <time datetime="{{.Date.Format "2006-01-02"}}">{{date .Date}}</time>
        <p>{{.Excerpt}}</p>
    </article>
{{end}}

.Excerpt is what editors write under Summary. .Date is a calendar day, so {{date .Date}} shows the same day in every site timezone. On a category's page .Category is that category and .Title its title, also for a published category without visible posts, which .Categories leaves out.

post.gohtml gets a single publishing.Post; its Body is the rendered Markdown, with media from the library already resolved. ImageURL is the post's Image as pages show it, at most 1600 pixels wide, with ImageSrcset, ImageWidth and ImageHeight for the img tag, and ImageOriginalURL is the uploaded file; all are empty for a post without one:

{{if .ImageURL}}<img src="{{.ImageURL}}" srcset="{{.ImageSrcset}}" sizes="100vw" width="{{.ImageWidth}}" height="{{.ImageHeight}}" alt="">{{end}}

Posts in a list have the same fields; {{image .ImageURL "400x300 crop"}} gives a copy cut to a fixed size around the focal point (see Media and images).

A post appears in each language it has a title and slug for. Posts are only reachable while the instance is placed on a page.

Show posts anywhere

The posts template function returns the newest published posts in the current language, up to a limit, for a home page or a sidebar; a limit of 0 or less returns all. Links point to the page the instance is placed on; while it is on no page in the current language, .URL of posts and their categories is empty.

{{range posts "news" 3}}
    <a href="{{.URL}}">{{.Title}}</a>
{{end}}

<a href="{{moduleURL "news"}}">All news</a>

Feeds

Each instance serves Atom and RSS feeds of its newest published posts below its page, in the page's language:

Address Routes Lists
/news/feed.atom, /news/feed.rss feed.atom, feed.rss the newest posts
/news/category/<slug>/feed.atom, .rss category-feed.atom, category-feed.rss the newest posts in one category

Core's head tags link to them with {{feedLinks}}: every page of the instance to the instance's feeds, and a category's page to that category's feeds as well; see Themes. A category that does not exist or is not published gets the site's 404 page, and so do the feeds while the instance is on no page.

  • The feeds list as many posts as the site setting Items in feeds says (Site settings › General, 20 by default). Their title is the page's title, or the category's, with the site's name, such as "News from My site", translated with the site's translations.
  • publishing.Module("news", "News", publishing.WithFeedSize(50)) makes the instance's feeds, those of its categories included, list 50 posts instead, whatever the setting says. The size is at most 100 (site.MaxFeedSize) and 0 follows the setting; the app does not start with another size.
  • An entry's summary is the post's excerpt, or else about the first 300 characters of its text. Its content is the post's Markdown with absolute addresses, and its images are shown as in e-mail: a JPEG at most 1200 pixels wide, or a PNG for a PNG with transparency.
  • Its categories are the post's categories, and the featured image is an enclosure.
  • It is published at the post's date and updated when its published revision was saved, if that was later.
  • Its ID is tag:<host>,<date created>:<key>/<post ID>, such as tag:example.com,2026-10-07:news/0199a3c1-…, so it stays the same when the post's slug or page changes. The host is that of Config.BaseURL, or else of the request: set BaseURL, so the IDs do not depend on the address a reader used.

Feeds are cached like pages and are not in sitemap.xml. Feeds for modules explains what core adds to them.

Structured data

A post's page describes the post to search engines as a schema.org Article. Core's head tags write it, with the site's own items, with {{structuredData}}; see Search and SEO. The Article of a post at /en/news/hello-harbour, one item of the page's @graph:

{
  "@type": "Article",
  "articleSection": ["Updates"],
  "author": {"@id": "https://example.com/#organization"},
  "dateModified": "2026-10-07T09:57:13Z",
  "datePublished": "2026-10-01",
  "description": "Boats arrived",
  "headline": "Hello harbour",
  "image": "https://example.com/media/v/vFm2YaBvKDUol6Ti/01a115cb-afb9-7d37-a62d-7512dda0e551_1200x630c.jpg",
  "inLanguage": "en",
  "mainEntityOfPage": {"@id": "https://example.com/en/news/hello-harbour"},
  "publisher": {"@id": "https://example.com/#organization"},
  "url": "https://example.com/en/news/hello-harbour"
}
  • headline is the post's title and description its excerpt.
  • image is the featured image as it is shown when the page is shared, a 1200 × 630 JPEG cut around its focal point.
  • datePublished is the post's date, dateModified when the revision shown was saved.
  • inLanguage is the page's language, and articleSection the titles of the post's categories.
  • author and publisher are the site's organisation, as posts name no author.

Empty values, such as the description of a post without an excerpt, are left out. The list of posts and the list of a category have no Article: they are an ItemList of the posts they show, in their order.

Content API

While the site's content API is on (Config.API), each instance serves its published posts and categories as JSON below /api/v1/<key>/, in the language of ?locale=, or the site's default language without it. With the instance news:

Endpoint Route name Answers with
/api/v1/news/posts api.news.posts The published posts, newest first, as a list
/api/v1/news/posts?category=<slug> The posts in one category; a category that does not exist or is not published answers 404 not_found
/api/v1/news/posts?slug=<slug> The post with that slug, such as hello-harbour for the module.path /hello-harbour that /api/v1/pages/resolve gives for a post's address
/api/v1/news/posts/{id} api.news.post One post, with its text
/api/v1/news/categories api.news.categories The published categories, in the editors' order, as a list

A post appears in each language it has a title and slug for, as on the site, and never as a draft. GET /api/v1/news/posts/{id}, with the long srcset values shortened:

{
  "id": "01a115cb-b0d3-7e8f-aab0-1307cf737b4c",
  "slug": "hello-harbour",
  "title": "Hello harbour",
  "excerpt": "Boats arrived",
  "body_html": "\u003cp\u003eThe \u003cem\u003eboats\u003c/em\u003e are in. \u003cimg src=\"https://example.com/media/v/wlN_-0Ps0_NzcXkN/01a115cb-afb9-7d37-a62d-7512dda0e551_1600x0.webp\" alt=\"Boats in the harbour\" srcset=\"…\" sizes=\"100vw\" width=\"1600\" height=\"900\"\u003e\u003c/p\u003e\n",
  "date": "2026-10-01T00:00:00Z",
  "updated_at": "2026-10-07T09:57:13.0598Z",
  "url": "https://example.com/en/news/hello-harbour",
  "api_url": "https://example.com/api/v1/news/posts/01a115cb-b0d3-7e8f-aab0-1307cf737b4c?locale=en",
  "image": {
    "url": "https://example.com/media/v/wlN_-0Ps0_NzcXkN/01a115cb-afb9-7d37-a62d-7512dda0e551_1600x0.webp",
    "srcset": "https://example.com/media/v/hJrpW0UZS6JpKfmT/01a115cb-afb9-7d37-a62d-7512dda0e551_480x0.webp 480w, …",
    "width": 1600,
    "height": 900,
    "alt": "Boats in the harbour",
    "original_url": "https://example.com/media/01a115cb-afb9-7d37-a62d-7512dda0e551.png"
  },
  "categories": [
    {
      "id": "01a115cb-b0b3-7a87-a76d-08aca70ac28e",
      "slug": "updates",
      "title": "Updates",
      "url": "https://example.com/en/news/category/updates",
      "posts_url": "https://example.com/api/v1/news/posts?category=updates\u0026locale=en"
    }
  ]
}
Field
body_html The post's text as HTML, with absolute addresses and images with their srcset; only /posts/{id} gives it
date The post's date, at midnight UTC
updated_at When the published revision was saved
url The post's page on the site; null while the instance is on no page in that language
api_url The post in the API
image The featured image as an image object, null without one
categories The published categories the post is in

A category has its id, slug and title, its list on the site as url, null while the instance is on no page, and its posts in the API as posts_url. /categories lists every published category with a title and slug in that language, also one without posts.

/posts/{id} sends a Last-Modified: the latest change of the post, of its categories, of the page the instance is on and of the post's media. A post in a category that was deleted gets none, and so do the lists; see Caching.

Editing

Each instance has its own section in the admin (/admin/news; /admin is the default admin path) with the list of posts and a link to its categories.

  • A post has a date, an optional image from the media library, categories, and per language a title, web address, summary and Markdown text. An empty Web address is made from the title when the post is saved.
  • Save draft creates a new revision as a draft: "Saved as a draft. Publish it to show it on the website." The published version stays online until a revision is published. Editors can preview a draft at the post's own address on the website, while the instance is placed on a page, and restore an earlier revision from the history.
  • The publication box plans publishing or unpublishing for a date and time in the site's timezone. Publishing by hand cancels the plan.
  • A site can require a review per instance in Site settings › Review. A post's revision then needs a reviewer's approval before it is published or planned; see Review before publishing.
  • Move to trash moves a post or a category to the trash, where it can be restored or deleted for good. Moving a post to the trash cancels its planned publication. A published post cannot be restored while another published post uses its web address, nor a category while another category does.
  • Categories are translated too, with a title and a web address per language, made from the title when left empty, and ordered by hand. They have no revisions: Save changes a category at once. The list only shows categories that have a visible post. Unpublished categories have no page, so they are not shown on posts or in the list.

Editors' rights are set per instance in the groups' permission matrix; categories share the rights of their instance.

Published posts appear in the site search and in sitemap.xml; sitemap.xml also lists the pages of the categories that have a published post in that language.

Links to addresses on the site in the text of published posts count for Links to a page: the editor of a page lists the posts that link to it, and Pages › Broken links lists their links that lead nowhere.

The editor of a published post lists the published content that links to the post and asks before it is unpublished or moved to the trash. A revision with a new Web address shows the old and the new address before it is published. With the redirects module, editors who may create redirects also get Unpublish and redirect, and Publish this revision comes with Redirect the old addresses to the new ones, ticked by default; see Links to an item. The editor of a published category does the same for the category's page, such as /news/category/tips: categories have no revisions, so saving one with a new Web address redirects its old address to the new one, for editors who may create redirects.

From Go

publishing.Of(app, "news") returns the instance once the app has started:

news := publishing.Of(app, "news")

latest, err := news.Latest(ctx, "en", 5)

While the instance is on no page in that language, Latest still returns the posts, with an empty URL for them and their categories.

Store() and Categories() give write access for imports and seed data, through the same rules the admin uses. Other modules propose posts with Draft, which saves an unpublished post for editors to review; Submissions uses it to turn visitors' news tips into drafts:

id, err := news.Draft(ctx, publishing.DraftInput{
    Locale: "en",
    Title:  "Street party on Saturday",
    Body:   "Everyone is welcome …",
    Note:   "Sent in by a visitor",
})
  • Submissions lets visitors send in news that editors accept as a draft post.
  • Comments adds reactions below posts.
  • Stats lists the most read posts.

Documentation

Overview

Package publishing is a news or blog module: dated posts with revisions, categories, a featured image and Markdown content. Add one instance per stream, for example:

app.Use(publishing.Module("news", "News"), publishing.Module("blog", "Blog"))

The website renders publishing/list.gohtml and publishing/post.gohtml; see ListView and Post for their data.

Below its page it serves Atom and RSS feeds of the newest posts, at feed.atom and feed.rss (routes feed.atom and feed.rss), and of each category, at category/<slug>/feed.atom and .rss (routes category-feed.atom and category-feed.rss); the layout links to them with {{feedLinks}}. They list as many posts as the site setting says (site.Settings.FeedLimit, 20 by default), with their text as content.

Index

Constants

This section is empty.

Variables

View Source
var ErrDraft = errors.New("publishing_invalid_draft")

ErrDraft is returned for a draft without a language or title.

Functions

func Module

func Module(key, title string, opts ...Option) webthings.Module

Module adds a Publishing instance. key is used in URLs of the admin and must be unique on the site; title is shown in the admin and the page editor.

Types

type APICategory

type APICategory struct {
	ID    string `json:"id"`
	Slug  string `json:"slug"`
	Title string `json:"title"`
	// URL is the category's list on the site, null while the instance is on
	// no page; PostsURL its posts in the API.
	URL      *string `json:"url"`
	PostsURL string  `json:"posts_url"`
}

APICategory is a published category in the content API, at /api/v1/<key>/categories and with its posts.

type APIPost

type APIPost struct {
	ID      string `json:"id"`
	Slug    string `json:"slug"`
	Title   string `json:"title"`
	Excerpt string `json:"excerpt"`
	// BodyHTML is the post's text as HTML (api.HTML); only the post's own
	// endpoint has it.
	BodyHTML string `json:"body_html,omitempty"`
	// Date is the post's date; UpdatedAt when its published revision was
	// saved.
	Date      time.Time `json:"date"`
	UpdatedAt time.Time `json:"updated_at"`
	// URL is the post's page, null while the instance is on no page;
	// APIURL the post in the API.
	URL        *string       `json:"url"`
	APIURL     string        `json:"api_url"`
	Image      *api.Image    `json:"image"`
	Categories []APICategory `json:"categories"`
}

APIPost is a published post in the content API (see package api), at /api/v1/<key>/posts/{id} and in the list of /api/v1/<key>/posts, which leaves out BodyHTML.

type Category

type Category struct {
	ID    string
	Title string
	// URL is the category's list, empty like Post.URL.
	URL    string
	Active bool
}

Category is a post category as templates see it.

type CategoryItem

type CategoryItem = content.Item[struct{}, CategoryText]

CategoryItem is a category with its translations; categories have no fields of their own.

type CategoryText

type CategoryText struct {
	Slug  string `db:"slug"`
	Title string `db:"title"`
}

CategoryText is a category's name and slug in one language; the category's posts are listed at /category/<slug> below the instance's page.

type DraftInput

type DraftInput struct {
	// Locale is the language of the text, for example "nl".
	Locale string
	Title  string
	// Slug is derived from the title when empty (see content.Slug).
	Slug    string
	Excerpt string
	// Body is Markdown.
	Body string
	// ImageID is a media asset for the featured image.
	ImageID string
	// Date is the post's date; zero is today.
	Date time.Time
	// Author is the ID of the account the revision is credited to, empty for
	// none. Note describes the revision in the history, for example who sent
	// the text in.
	Author string
	Note   string
}

DraftInput is a post another module proposes, for example an accepted visitor submission. Editors review and publish it in the admin.

type ListView

type ListView struct {
	Title string
	Posts []Post
	// Categories that have at least one visible post.
	Categories []Category
	// Category is set when the list is filtered.
	Category *Category
}

ListView is the data of publishing/list.gohtml.

type Option

type Option func(*options)

Option changes how an instance works.

func WithFeedSize

func WithFeedSize(size int) Option

WithFeedSize makes the instance's feeds list the size newest posts, the main feed and those of the categories alike, rather than as many as the site setting says (site.Settings.FeedLimit); at most site.MaxFeedSize.

type Post

type Post struct {
	ID      string
	Title   string
	Excerpt string
	Body    template.HTML
	// Date is a calendar date (see frontend.Civil): {{date .Date}} shows the
	// same day in every site timezone.
	Date time.Time
	// URL is the post's page. Latest and the posts template function leave
	// it empty while the instance is on no page.
	URL string
	// ImageURL is the featured image as pages show it, at most media.ImageWidth
	// wide (see frontend.Frontend.Image); ImageSrcset lists smaller
	// copies for a srcset attribute, ImageWidth and ImageHeight are the
	// size of ImageURL and ImageOriginalURL is the uploaded file, for
	// downloads. All are empty when the post has no featured image.
	ImageURL         string
	ImageSrcset      string
	ImageOriginalURL string
	ImageWidth       int
	ImageHeight      int
	Categories       []Category
}

Post is a published post as templates see it.

type PostFields

type PostFields struct {
	Date       time.Time   `db:"date"`
	Image      string      `db:"image_id,null"`
	Categories content.IDs `db:"category_ids"`
}

PostFields are the language-independent parts of a post.

type PostItem

type PostItem = content.Item[PostFields, PostText]

PostItem is a post with its translations, as the store keeps it.

type PostText

type PostText struct {
	Slug    string `db:"slug"`
	Title   string `db:"title"`
	Excerpt string `db:"excerpt"`
	Body    string `db:"body"`
}

PostText is a post in one language. Body is Markdown.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is one Publishing instance, for the website's own code (for example "latest news" on the home page).

func Of

func Of(app *webthings.App, key string) *Service

Of returns the Publishing instance with key from a started app, or nil.

func (*Service) Categories

func (s *Service) Categories() content.Store[struct{}, CategoryText]

Categories gives write access to this instance's categories.

func (*Service) Draft

func (s *Service) Draft(ctx context.Context, input DraftInput) (string, error)

Draft saves a new, unpublished post with the instance's own rules and returns its ID.

func (*Service) Latest

func (s *Service) Latest(ctx context.Context, locale string, limit int) ([]Post, error)

Latest returns up to limit of the newest published posts in locale, for use outside the module's own pages; a limit of 0 or less returns all of them. Links point to the page the module is mounted on. While the module is on no page in locale, posts have no address: Post.URL and the categories' URL are empty.

func (*Service) Store

func (s *Service) Store() content.Store[PostFields, PostText]

Store gives write access to this instance's posts, for imports and seeds. Admin edits go through the same store.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL