Skip to content
internal/blog/editor
On this page

The authoring app's local server: the repository registry, document read and write, in-memory previews over server-sent events, and buffer analysis.

#internal/blog/editor

#internal/blog/editor

Package editor is the authoring app's local server: registry, documents, preview, stream.

A single-user, local-only HTTP server on the standard library alone. It serves seven things:

- the shell (the editor's own page and module, plus tinymoon's asset tree); - the registry, and each local entry's posts; - document read and write -- a write lands in the working tree, atomically; - previews, rendered in memory and pushed down one server-sent-events channel; - analysis of an unsaved buffer -- spelling and lint findings, from the engines the check itself runs; - link targets across every registered repository, addressed the way a post has to address them; - the publish surface -- the command's own declaration, the list of posts a publish would make public, and the consented invocation.

Analysis is a SIBLING of the preview, not a passenger on it. They fail independently and that is the whole reason: a buffer that cannot render -- no date in its frontmatter, a directive nothing answers -- is the buffer whose diagnostics are worth the most, and one endpoint that renders and analyses would lose them to the render's refusal. They also differ in what they produce: a preview is one document broadcast to every listener on the event stream, while analysis answers the request that asked for it.

The preview is the part with a property worth stating. It goes through [github.com/smm-h/selfdoc/internal/render.Post], which is the publish renderer handed an in-memory buffer instead of a file: same directive resolution, same HTML pass, same site-level addressing, and no write anywhere. So what the author approves on screen is the bytes readers get, and asking for a preview cannot change the tree it previews. Both halves are asserted by the suite.

Remote registry entries are validated but not served. Every path that would have to reach one refuses with "remote entries not yet served" rather than half-working.

#ManifestRel

Go go
const ManifestRel = ".selfdoc/manifest.json"

ManifestRel is where a project keeps the manifest the editor reads.

#PublishCommand

Go go
const PublishCommand = "blog.post.publish"

PublishCommand is the command the surface drives, as strictcli addresses it.

#Scope

Go go
const Scope = "repository"

Scope is what the publish reaches, stated the way the surface has to state it.

#ConsentParameter

Go go
const ConsentParameter = "approve_consequential"

ConsentParameter is the name of the parameter the browser sets to carry the human's consent.

#ScopeNote

Go go
const ScopeNote = "This publishes every non-draft post in this repository -- not just the " +

ScopeNote is rendered verbatim by the consent dialog. The command publishes a project, not a document, and a surface that implied otherwise would be describing something the button does not do.

#HeartbeatInterval

Go go
const HeartbeatInterval = 15 * time.Second

HeartbeatInterval is how often an idle event stream emits a comment, so a client that went away is noticed rather than held forever.

#PostDepth

Go go
var PostDepth = len(strings.Split(shared.TargetOutputPath(shared.PostTarget("slug")), "/")) - 1

PostDepth is how many directories a post sits below the site root. Derived from the post address itself ("blog//index.html" -> 2) so the hop and the address can never disagree.

#ToSiteRoot

Go go
var ToSiteRoot = strings.Repeat("../", PostDepth)

ToSiteRoot is the relative hop from a post's own directory back to the site root.

#SpellingFinding

Go go
type SpellingFinding struct

SpellingFinding is one unrecognized word, in both coordinate systems: Line and Column (1-based, what a diagnostic reads like) and From / To (half-open character offsets over the whole buffer, what the editor's decoration interface takes).

#LintFinding

Go go
type LintFinding struct

LintFinding is one lint the project's rules report for a buffer. Line is nil for a page-level finding.

#Analysis

Go go
type Analysis struct

Analysis is both lanes for one buffer, in the shape the shell renders.

#Error

Go go
type Error struct

Error reports that a request cannot be served, for the reason the message states, and carries the status the server answers it with.

It is the Go counterpart of the Python surface's EditorError hierarchy, where each subclass carried a status attribute: the status is a field here rather than a type, so a caller reads one answer instead of matching a hierarchy. Recognize it with errors.As to render a refusal as one line instead of an unexpected internal failure.

#PostSummary

Go go
type PostSummary struct

PostSummary is one post as the sidebar and the publish plan carry it: only what a list needs, because the post bodies are fetched one at a time, when one is opened.

#ManifestError

Go go
type ManifestError struct

ManifestError reports that a manifest exists but cannot be read, and the message says how.

#Target

Go go
type Target struct

Target is one link target: a page, or a heading on one.

Each target carries the address twice: Address is site-relative (what the assembly serves it at) and Href is what a post writes to reach it. Both are derived, never typed out.

PageTitle and Level are carried by a section target only, which is why they are pointers: a page target's own title is Title, and a page has no heading level. The Python built two differently-keyed dicts for the two kinds, and the absent members are how that shape is reproduced.

#TargetIndex

Go go
type TargetIndex struct

TargetIndex is every registry entry's link targets, re-read when a manifest changes.

The editor asks for completions on a keystroke, so the manifests are not re-parsed each time -- but they are also not cached forever: the key is the manifest's own size and modification time, so a build that rewrites one is picked up on the next keystroke with no restart.

It is safe to use from several goroutines: the cache carries its own mutex, because the completions of two open shells are two concurrent requests.

#Grant

Go go
type Grant struct

Grant is one grant the publish command declares, in the shape strictcli's own schema dump writes it.

#Descriptor

Go go
type Descriptor struct

Descriptor is the publish command's declaration, as the consent dialog renders it.

Effect, Consequential, Help and Grants are the COMMAND's own answers, projected out of its declaration by the [Publisher] rather than copied here. Command, Scope, ScopeNote and ConsentParameter are the surface's own: [PublishDescriptor] fills them from this package's constants and ignores whatever a Publisher put there, because what the button reaches is a property of the surface, not something the command declares.

#Plan

Go go
type Plan struct

Plan is what a publish of one repository would make public.

Publishing and Withheld come from the publish's own post discovery, through [Publisher.Plan]: which posts publish is the project's answer, read off the same discovery the publish itself runs rather than derived in the browser. Repo, Path, Project and Assembly are filled by [PublishPlan] from the registry entry and the project config.

#PublishResult

Go go
type PublishResult struct

PublishResult is what a completed publish reports back to the author.

Stdout carries everything the invocation wrote, because the invocation is handed one writer; Stderr is therefore always empty. The shell renders the two concatenated, so the author sees the whole output either way.

#Publisher

Go go
type Publisher interface

Publisher is the publish surface's door into the CLI: the command's own declaration, the plan its discovery produces, and the consented invocation.

The editor holds no CLI application of its own. The command layer implements this and injects it when it constructs the server, which is what keeps the engine free of a dependency on the command tree it is called from, and what lets the suite drive the whole surface against a publisher that records instead of pushing.

#Refusal

Go go
type Refusal struct

Refusal reports that the consent regime refused the publish call.

It is the one error shape a [Publisher] must return for a refusal, and the message is the framework's own, forwarded verbatim: nothing here decides whether a call is allowed, so nothing here words the refusal either.

#Server

Go go
type Server struct

Server is one bound editor server: a listener on loopback, the routes over it, and the stop that releases every held event stream.

It binds at construction so the caller can read the port before anything is served, which is what lets the suite ask for an ephemeral one.

#SSEChannel

Go go
type SSEChannel struct

SSEChannel is the one event stream every connected shell listens on.

#StateOptions

Go go
type StateOptions struct

StateOptions is what one editor state is built from.

Every member is declared rather than defaulted, with two exceptions the zero value really answers: a nil UI means the front-end this build embeds, and a nil Tinymoon means no tinymoon tree is configured -- which every /tinymoon/ request then refuses by name.

#State

Go go
type State struct

State is everything one running editor holds: registry, assets, live previews, the event stream and the publish surface.

It is safe to use from several goroutines: the preview map and the event stream each carry their own mutex, and everything else is immutable after construction.

#SpellingFindings

Go go
func SpellingFindings(content, file string) ([]SpellingFinding, error)

SpellingFindings returns every unrecognized word in content, as editor decoration spans.

content is the buffer, frontmatter included, and file is the name the engine puts on each diagnostic.

Offsets are character offsets, not byte offsets: the editor holds the buffer as text and paints over character positions, and the engine's columns are character columns too.

It is an error when a reported word is not at the offset the mapping computes. That is a defect in this mapping or in the engine's columns, and painting a mark over the wrong word is worse than saying so.

#LintFindings

Go go
func LintFindings(

LintFindings returns every lint the project's rules report for this buffer.

entry is the local registry entry the post belongs to, rel is the post's path relative to the posts directory, content is the buffer with its frontmatter, and a nil cfg loads the project config.

#AnalyzeBuffer

Go go
func AnalyzeBuffer(

AnalyzeBuffer returns both lanes for one buffer, in the shape the shell renders.

#RequireLocal

Go go
func RequireLocal(entry registry.Entry) (string, error)

RequireLocal returns the working tree of entry, or refuses.

The refusal is the one every path that would have to reach a remote entry answers, so a remote entry is never half-served.

#RepoConfig

Go go
func RepoConfig(entry registry.Entry) (config.Config, error)

RepoConfig returns the project config of a local entry, or a refusal naming the entry.

#PostsDirOf

Go go
func PostsDirOf(entry registry.Entry, cfg config.Config) (string, error)

PostsDirOf returns the absolute posts directory of a local entry.

A nil cfg loads the entry's config.

#RepoPosts

Go go
func RepoPosts(entry registry.Entry, handle *effects.Handle) ([]PostSummary, error)

RepoPosts returns every post a local entry declares, newest first.

#SafeRel

Go go
func SafeRel(rel string) (string, error)

SafeRel returns a post path relative to the posts directory, or a refusal.

The editor addresses documents by a path the browser supplies, so this is the boundary where a path that leaves the posts directory has to stop.

#PostPathOf

Go go
func PostPathOf(entry registry.Entry, rel string) (string, error)

PostPathOf returns the absolute path of one post inside a local entry.

#ReadPost

Go go
func ReadPost(entry registry.Entry, rel string) (string, error)

ReadPost returns the saved source of one post.

#SavePost

Go go
func SavePost(entry registry.Entry, rel, content string, handle *effects.Handle) (string, error)

SavePost writes a buffer to the working tree, atomically, and returns the file it wrote.

Atomic because the tree is shared with everything else that reads it -- a build, a check, another editor -- and a half-written post is a post that fails to parse for whatever looked at it mid-write.

#PostSlug

Go go
func PostSlug(rel, content string) (string, error)

PostSlug returns the slug a buffer would publish under.

#RenderPreview

Go go
func RenderPreview(entry registry.Entry, rel, content string, handle *effects.Handle) (string, error)

RenderPreview renders a buffer to the exact HTML publishing it would produce.

One renderer: this is [github.com/smm-h/selfdoc/internal/render.Post], which is the build's own page pass over an in-memory overlay. The bytes equal what a posts-target build writes for the same source saved to disk, and nothing is written anywhere.

Drafts are the one case with no published counterpart to equal, so a buffer that declares "draft: true" is rendered as the drafts build renders it. The decision is read off the buffer, never off a mode the server carries: the same buffer previews the same way every time.

#PreviewAddress

Go go
func PreviewAddress(slug string) string

PreviewAddress returns where a previewed post is served, mirroring its published address.

#TargetHref

Go go
func TargetHref(projectSlug, pagePath, anchor string) string

TargetHref returns the link a post writes to reach pagePath of projectSlug.

Document-relative, from the post's own emitted directory: the site has to resolve under any mount point, so an origin-absolute link would be a defect the reference check reports.

#LoadManifest

Go go
func LoadManifest(path string) (map[string]any, error)

LoadManifest reads one project manifest.

It returns a nil manifest and no error when there is no manifest at path. Absence is genuine: a project that has never been built has no manifest, and offering nothing from it is the correct answer.

A file that exists and is not readable JSON is a [ManifestError]. It was written by a build that meant something by it, so guessing is worse than saying so.

#ManifestTargets

Go go
func ManifestTargets(manifest map[string]any, repoName string) []Target

ManifestTargets returns every link target one manifest offers, pages first, then the sections of each page.

#NewTargetIndex

Go go
func NewTargetIndex(reg *registry.Registry) *TargetIndex

NewTargetIndex builds the index over a registry.

#PublishDescriptor

Go go
func PublishDescriptor(publisher Publisher) Descriptor

PublishDescriptor returns the publish declaration the consent dialog is rendered from.

#PublishPlan

Go go
func PublishPlan(entry registry.Entry, publisher Publisher) (Plan, error)

PublishPlan returns what a publish of entry would make public.

#RunPublish

Go go
func RunPublish(entry registry.Entry, publisher Publisher, approveConsequential bool) (PublishResult, error)

RunPublish invokes the publish for entry, carrying the consent the human gave.

The consent is passed through untouched. False (or absent) is not handled here at all -- the consent regime refuses the call, which is the point: the refusal comes from the regime, not from a condition this function could forget to write.

A refusal is a 403 carrying the framework's message; a non-zero exit is a 500 carrying whatever the invocation printed; any other error is returned as it came, so the server answers it as the unexpected failure it is.

#NewServer

Go go
func NewServer(state *State, port int) (*Server, error)

NewServer binds the editor server to loopback on port.

port 0 binds an ephemeral port, which is what the suite uses; the command itself requires an explicit one. The bind address is not configurable: the editor writes working trees and authenticates nothing, so it is reachable from this machine only.

#Serve

Go go
func Serve(state *State, port int, onReady func(port int)) (int, error)

Serve runs the editor until interrupted, then stops cleanly, and reports the exit code the command exits with.

Ctrl-C is the graceful stop: the accept loop ends, every held event stream is released, and the listening socket is closed. A termination signal is treated the same way, because the alternative is a process killed with a browser still holding a stream.

onReady, when given, is called with the bound port once the socket is listening and before anything is served.

#NewState

Go go
func NewState(opts StateOptions) (*State, error)

NewState builds the state one running editor holds, or refuses.

A missing registry, publisher or effects handle is a refusal rather than a zero value: each of them is something the caller decided, and an editor that discovered one at request time would answer a broken route on a surface the shell already drew.

#Error.Error

Go go
func (e *Error) Error() string { return e.Message }

Error returns the diagnostic.

#ManifestError.Error

Go go
func (e *ManifestError) Error() string { return e.Message }

Error returns the diagnostic.

#TargetIndex.AllTargets

Go go
func (index *TargetIndex) AllTargets() ([]Target, error)

AllTargets returns every target from every local registry entry, in registry order.

#TargetIndex.Search

Go go
func (index *TargetIndex) Search(query string, limit int) ([]Target, error)

Search returns the targets matching query, pages before their own sections, and at most limit of them.

The match is a case-insensitive substring over what an author would type: the title, the address, the project's name and the page path. An empty query matches everything, which is what makes the popup useful the moment a link is opened rather than only after some prefix is typed.

#Refusal.Error

Go go
func (e *Refusal) Error() string { return e.Message }

Error returns the refusal, verbatim.

#Server.Port

Go go
func (s *Server) Port() int

Port returns the port the server bound.

#Server.Serve

Go go
func (s *Server) Serve() error

Serve serves until [Server.Stop], and reports nothing when that is why it returned.

#Server.Stop

Go go
func (s *Server) Stop() error

Stop ends the accept loop, releases every held event stream and closes the listening socket.

The streams are released FIRST: each one is a request still in flight, and a shutdown that waited for them before telling them to go would wait for as long as a browser tab stays open.

#SSEChannel.Count

Go go
func (ch *SSEChannel) Count() int

Count returns how many clients hold the stream open.

#SSEChannel.Broadcast

Go go
func (ch *SSEChannel) Broadcast(event string, payload any) error

Broadcast pushes one event to every client, dropping the ones that went away.

#State.Registry

Go go
func (s *State) Registry() *registry.Registry { return s.registry }

Registry returns the repositories this editor may open.

#State.Channel

Go go
func (s *State) Channel() *SSEChannel { return s.channel }

Channel returns the one event stream every connected shell listens on.

#State.Targets

Go go
func (s *State) Targets() *TargetIndex { return s.targets }

Targets returns the cross-repository link target index.

#State.Handle

Go go
func (s *State) Handle() *effects.Handle { return s.handle }

Handle returns the effects handle the editor's writes go through.

#State.Publisher

Go go
func (s *State) Publisher() Publisher { return s.publisher }

Publisher returns the publish surface's door into the CLI.

#State.StorePreview

Go go
func (s *State) StorePreview(repoName, address, html string)

StorePreview holds one rendered preview under the address it publishes at.

#State.GetPreview

Go go
func (s *State) GetPreview(repoName, address string) (string, bool)

GetPreview returns a held preview, reporting whether one is held.

Search