surveygo

package module
v2.2.0 Latest Latest
Warning

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

Go to latest
Published: Feb 12, 2026 License: MIT Imports: 12 Imported by: 0

README

SurveyGo Gopher

Go Version Go Reference License Go Report Card Latest Release Last Commit Repo Size Contributors AI Agent Skill Ask DeepWiki

Go library for building, validating, and rendering dynamic surveys with conditional logic, grouped questions, and multi-format output (CSV, HTML, JSON, TipTap).

HTML Survey Card    Definition Tree

Table of Contents

Key Features

Feature Description
20+ Question Types Choice, text, asset, toggle, external, datetime
Conditional Logic DependsOn (OR-of-ANDs) + option-triggered groups
Answer Validation Type-specific reviewers with detailed error reporting
Multi-Format Render CSV, HTML, JSON (SurveyCard), TipTap in single pass
Custom Expressions expr-lang/expr for answer transformation (AnswerExpr)
Survey Visualization Interactive tree (go-echarts) + JSON group hierarchy
Runtime Modification Add/remove/update questions and groups dynamically
Grouped Answers Repeatable groups with cartesian CSV expansion
BSON Support MongoDB-ready with BSON tags on all structs
Agent Skill Built-inAI coding agent guidance

Installation

go get github.com/rendis/surveygo/v2
import surveygo "github.com/rendis/surveygo/v2"
import "github.com/rendis/surveygo/v2/render"

Requirements: Go 1.25+

Quick Start

Parse & Validate

// Parse survey from JSON
survey, err := surveygo.ParseFromBytes(jsonData)

// Provide answers
answers := surveygo.Answers{
    "event_rating":  {"good"},
    "favorite_game": {"zombie_apocalypse"},
    "name":          {"John Doe"},
    "email":         {"john@example.com"},
}

// Validate answers
resume, err := survey.ReviewAnswers(answers)
fmt.Printf("Answered: %d/%d\n", resume.TotalQuestionsAnswered, resume.TotalQuestions)
fmt.Printf("Errors: %v\n", resume.InvalidAnswers)

Render Output

import "github.com/rendis/surveygo/v2/render"

// CSV
csv, err := render.AnswersToCSV(survey, answers)

// HTML (independent CSS)
html, err := render.AnswersToHTML(survey, answers)
// html.HTML, html.CSS
custom := html.WithCSSPath("/assets/survey.css") // replace CSS href

// TipTap document
tiptap, err := render.AnswersToTipTap(survey, answers)

// Multiple formats in one pass
result, err := render.AnswersTo(survey, answers, render.OutputOptions{
    CSV:  true,
    JSON: true,
    HTML: true,
})
// result.CSV, result.JSON, result.HTML, result.TipTap

Build Programmatically

desc := "Annual feedback"
survey, err := surveygo.NewSurvey("Event Survey", "1.0", &desc)

// Add question from JSON
err = survey.AddQuestionJson(`{
    "nameId": "rating",
    "visible": true,
    "type": "radio",
    "label": "How would you rate the event?",
    "required": true,
    "value": {
        "options": [
            {"nameId": "great", "label": "Great"},
            {"nameId": "good", "label": "Good"},
            {"nameId": "meh", "label": "Meh"}
        ]
    }
}`)

// Add to group
err = survey.AddQuestionToGroup("rating", "grp-general", -1)

See the example/ directory for complete working examples.

Question Types

Category Types Description
Choice single_select, multi_select, radio, checkbox Options with labels; can trigger groups via groupsIds
Toggle toggle On/off switch with custom labels
Text input_text, text_area, email, telephone, information, identification_number Text input with type-specific validation
DateTime date_time Date/time with configurable format
Asset image, video, audio, document File upload with size/type constraints
External external_question Integration with external survey systems

For complete field definitions and JSON structure, see Survey Structure Reference.

Conditional Logic (DependsOn)

Questions and groups can have a dependsOn field that controls visibility based on selections in other questions. Structure is [][]DependsOn (OR of ANDs):

  • Outer array: OR conditions (any group matches = visible)
  • Inner array: AND conditions (all must match)
"dependsOn": [
  [{ "questionNameId": "rating", "optionNameId": "terrible" }],
  [
    { "questionNameId": "rating", "optionNameId": "meh" },
    { "questionNameId": "attendance", "optionNameId": "would_not_attend" }
  ]
]

This shows the element if the user selected "terrible" OR (selected "meh" AND would not attend).

During ReviewAnswers(), questions/groups with unsatisfied dependsOn are excluded from totals -- required questions with unmet conditions are not expected to be answered.

dependsOn can only reference choice-type questions (single_select, multi_select, radio, checkbox, toggle).

Render Package

The render package generates survey outputs from definitions and answers.

Answers to Outputs

Function Returns Description
AnswersToCSV(survey, answers, checkMark...) []byte, error CSV with cartesian expansion for repeat groups
AnswersToJSON(survey, answers) *SurveyCard, error Structured survey card
AnswersToHTML(survey, answers) *HTMLResult, error HTML + CSS (independent)
HTMLResult.WithCSSPath(path) *HTMLResult Replace CSS href in HTML
AnswersToTipTap(survey, answers) *TipTapNode, error TipTap-compatible document
AnswersTo(survey, answers, opts) *AnswersResult, error Multiple formats, single pass

Definition Tree

Function Returns Description
DefinitionTreeJSON(survey) *GroupTree, error Group hierarchy with cycle detection
DefinitionTreeHTML(survey) []byte, error Interactive tree visualization (go-echarts)
DefinitionTree(survey) *TreeResult, error Both HTML + JSON

CheckMark (CSV Boolean Columns)

Customize selected/not-selected marks for multi-select, checkbox, and toggle CSV columns:

csv, err := render.AnswersToCSV(survey, answers, &render.CheckMark{
    Selected:    "x",
    NotSelected: "",
})
// Defaults to "true"/"false" when nil

AnswerExpr

When a question has answerExpr set, the render package evaluates it using expr-lang/expr and uses the result instead of default type-based extraction. Falls back silently on error.

Environment variables:

  • ans -- []any raw answer data for the question
  • options -- map[string]string (nameId to label), only for choice-type questions

Examples:

ans[1]                       // extract phone number only (skip country code)
ans[0] + " " + ans[1]        // concatenate country code + number
ans[0] ? "Yes" : "No"        // toggle to text
options[ans[0]]              // resolve selected option to its label

API Overview

Construction & Serialization

Function Description
NewSurvey(title, version, desc) Create new survey
ParseFromBytes(b) Parse from JSON bytes
ParseFromJsonStr(s) Parse from JSON string
survey.ToJson() Serialize to JSON string
survey.ToMap() Serialize to map

Core Operations

Method Description
ValidateSurvey() Validate structure + cross-reference consistency
ReviewAnswers(ans) Validate answers, return *SurveyResume
TranslateAnswers(ans, ignoreUnknown) Convert raw answers to human-readable labels
GroupAnswersByType(ans) Group answers by question type

Question Management

Method Description
AddQuestion(q) Add question (also AddQuestionJson, AddQuestionMap, AddQuestionBytes)
UpdateQuestion(q) Update question (also Json/Map/Bytes variants)
AddOrUpdateQuestion(q) Upsert question (also Json/Map/Bytes variants)
RemoveQuestion(nameId) Remove question + clean up DependsOn refs
GetQuestionsAssignments() Map of questionNameId to groupNameId
GetAssetQuestions() List asset-type questions

Group Management

Method Description
AddGroup(g) Add group (also AddGroupJson, AddGroupMap, AddGroupBytes)
UpdateGroup(g) Update group (also Json/Map/Bytes variants)
RemoveGroup(nameId) Remove group
AddQuestionToGroup(qId, gId, pos) Assign question to group at position (-1 = end)
RemoveQuestionFromGroup(qId, gId) Unassign question from group
UpdateGroupQuestions(gId, qIds) Replace group's question list
UpdateGroupsOrder(order) Reorder top-level groups
EnableGroup(nameId) / DisableGroup(nameId) Toggle group visibility

Query Helpers

Method Description
GetDisabledQuestions() Questions in disabled groups
GetEnabledQuestions() Questions in enabled groups
GetRequiredQuestions() Required + enabled questions
GetOptionalQuestions() Optional + enabled questions

Testing

go test ./...

Run the example application:

go run example/main.go

The example/ directory includes two survey JSON files (survey.json, ecommerce_survey.json) demonstrating question types, grouped answers, and validation.

Documentation

Document Description
Survey Structure Reference Complete JSON schema for all question types
Example Application Working code with survey JSON samples

AI Agent Skill

SurveyGo includes an Agent Skill that provides AI coding agents (Claude Code, Cursor, etc.) with structured guidance for consuming this library.

Install via skills.sh

npx skills add https://github.com/rendis/surveygo --skill surveygo
ln -s /path/to/surveygo/skills/surveygo ~/.claude/skills/surveygo

Or use the distributable package: skills/surveygo.skill

Tech Stack

Component Technology
Language Go 1.25+
Validation go-playground/validator/v10
Expressions expr-lang/expr
Visualization go-echarts/go-echarts/v2
BSON go.mongodb.org/mongo-driver

License

MIT -- Copyright (c) 2025 rendis


Built for surveys that adapt.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var SurveyValidator = newSurveyValidator(validatorTranslator)

Functions

func TranslateValidationErrors

func TranslateValidationErrors(err error) []error

Types

type Answers

type Answers map[string][]any

Answers is a map with the answers provided by the user. The key is the question NameId (Question.NameId).

type GroupTotalsResume added in v2.1.0

type GroupTotalsResume struct {
	TotalsResume `json:",inline" bson:",inline"`
	AnswerGroups int `json:"answersGroups,omitempty" bson:"answersGroups,omitempty"`
}

GroupTotalsResume contains the resume of a group based on the answers provided.

type InvalidAnswerError

type InvalidAnswerError struct {
	QuestionNameId string `json:"questionNameId,omitempty" bson:"questionNameId,omitempty"`
	Answer         any    `json:"answer,omitempty" bson:"answer,omitempty"`
	Error          string `json:"error,omitempty" bson:"error,omitempty"`
}

type Survey

type Survey struct {
	// NameId is the identifier of the survey.
	// Validations:
	// - required
	// - valid name id
	NameId string `json:"nameId" bson:"nameId" validate:"required,validNameId"`

	// Title is the title of the survey.
	// Validations:
	//	- required
	//	- min length: 1
	Title string `json:"title,omitempty" bson:"title,omitempty" validate:"required,min=1"`

	// Version is the version of the survey.
	// Validations:
	//	- required
	//	- min length: 1
	Version string `json:"version,omitempty" bson:"version,omitempty" validate:"required,min=1"`

	// Description is the description of the survey.
	// Validations:
	//	- optional
	//	- min length: 1
	Description *string `json:"description,omitempty" bson:"description,omitempty" validate:"omitempty"`

	// Questions is a map with all the questions in the survey.
	// The key is the question NameId (Question.NameId).
	// Validations:
	//	- required
	//	- min length: 1
	//	- each question must be valid
	Questions map[string]*question.Question `json:"questions,omitempty" bson:"questions,omitempty" validate:"required,dive"`

	// Groups is a map with all the groups in the survey.
	// The key is the group NameId (Group.NameId).
	// Validations:
	//	- required
	//	- min length: 1
	//	- each group must be valid
	Groups map[string]*question.Group `json:"groups,omitempty" bson:"groups,omitempty" validate:"required,dive"`

	// GroupsOrder is a list of group name ids that defines the order of the groups in the survey.
	// Validations:
	//	- required
	//	- min length: 1
	GroupsOrder []string `json:"groupsOrder,omitempty" bson:"groupsOrder,omitempty" validate:"required"`

	// Metadata is a map with additional information about the survey.
	Metadata map[string]any `json:"metadata,omitempty" bson:"metadata,omitempty" validate:"omitempty"`
}

Survey is a struct representation of a survey.

func NewSurvey

func NewSurvey(title, version string, description *string) (*Survey, error)

NewSurvey creates a new Survey instance with the given title, version, and description. Args:

  • nameId: the name id of the survey (required)
  • title: the title of the survey (required)
  • version: the version of the survey (required)
  • description: the description of the survey (optional)

Returns:

  • *Survey: the new survey instance
  • error: if an error occurred

func ParseFromBytes

func ParseFromBytes(b []byte) (*Survey, error)

ParseFromBytes converts the given json byte slice into a Survey instance.

func ParseFromJsonStr

func ParseFromJsonStr(jsonSurvey string) (*Survey, error)

ParseFromJsonStr converts the given json string into a Survey instance.

func (*Survey) AddGroup

func (s *Survey) AddGroup(g *question.Group) error

AddGroup adds a group to the survey. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) AddGroupBytes

func (s *Survey) AddGroupBytes(g []byte) error

AddGroupBytes adds a group to the survey given its representation as a byte array It also validates the group and checks if the group is consistent with the survey.

func (*Survey) AddGroupJson

func (s *Survey) AddGroupJson(g string) error

AddGroupJson adds a group to the survey given its representation as a JSON string It also validates the group and checks if the group is consistent with the survey.

func (*Survey) AddGroupMap

func (s *Survey) AddGroupMap(g map[string]any) error

AddGroupMap adds a group to the survey given its representation as a map[string]any It also validates the group and checks if the group is consistent with the survey.

func (*Survey) AddOrUpdateGroupBytes added in v2.1.0

func (s *Survey) AddOrUpdateGroupBytes(g []byte) error

AddOrUpdateGroupBytes adds or updates a group in the survey given its representation as a byte array. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) AddOrUpdateGroupJson added in v2.1.0

func (s *Survey) AddOrUpdateGroupJson(g string) error

AddOrUpdateGroupJson adds or updates a group in the survey given its representation as a JSON string. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) AddOrUpdateGroupMap added in v2.1.0

func (s *Survey) AddOrUpdateGroupMap(g map[string]any) error

AddOrUpdateGroupMap adds or updates a group in the survey given its representation as a map. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) AddOrUpdateQuestion

func (s *Survey) AddOrUpdateQuestion(q *question.Question) error

AddOrUpdateQuestion adds a question to the survey if it does not exist, or updates it if it does. It also validates the question and checks if the question is consistent with the survey.

func (*Survey) AddOrUpdateQuestionBytes

func (s *Survey) AddOrUpdateQuestionBytes(qb []byte) error

AddOrUpdateQuestionBytes adds a question to the survey if it does not exist, or updates it if it does. It also validates the question and checks if the question is consistent with the survey.

func (*Survey) AddOrUpdateQuestionJson

func (s *Survey) AddOrUpdateQuestionJson(qs string) error

AddOrUpdateQuestionJson adds a question to the survey if it does not exist, or updates it if it does. It also validates the question and checks if the question is consistent with the survey.

func (*Survey) AddOrUpdateQuestionMap

func (s *Survey) AddOrUpdateQuestionMap(qm map[string]any) error

AddOrUpdateQuestionMap adds a question to the survey if it does not exist, or updates it if it does. It also validates the question and checks if the question is consistent with the survey.

func (*Survey) AddQuestion

func (s *Survey) AddQuestion(q *question.Question) error

AddQuestion adds a question to the survey. It also validates the question and checks if the question is consistent with the survey.

func (*Survey) AddQuestionBytes

func (s *Survey) AddQuestionBytes(qb []byte) error

AddQuestionBytes adds a question to the survey given its representation as a byte array It also validates the question and checks if the question is consistent with the survey.

func (*Survey) AddQuestionJson

func (s *Survey) AddQuestionJson(qs string) error

AddQuestionJson adds a question to the survey given its representation as a JSON string It also validates the question and checks if the question is consistent with the survey.

func (*Survey) AddQuestionMap

func (s *Survey) AddQuestionMap(qm map[string]any) error

AddQuestionMap adds a question to the survey given its representation as a map[string]any It also validates the question and checks if the question is consistent with the survey.

func (*Survey) AddQuestionToGroup

func (s *Survey) AddQuestionToGroup(questionNameId, groupNameId string, position int) error

AddQuestionToGroup adds a question to a group in the survey. Args: * questionNameId: the nameId of the question to add. * groupNameId: the nameId of the group to add the question to. * position: the position of the question in the group. If position is -1, the question will be added at the end of the group. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) DisableGroup added in v2.1.0

func (s *Survey) DisableGroup(groupNameId string) error

DisableGroup disables a group in the survey.

func (*Survey) EnableGroup added in v2.1.0

func (s *Survey) EnableGroup(groupNameId string) error

EnableGroup enables a group in the survey.

func (*Survey) GetAssetQuestions added in v2.1.0

func (s *Survey) GetAssetQuestions() []*question.Question

GetAssetQuestions returns all the asset questions in the survey. Asset questions are questions that have a type of image, video, audio or document.

func (*Survey) GetDisabledQuestions added in v2.1.0

func (s *Survey) GetDisabledQuestions() map[string]bool

GetDisabledQuestions returns a map with the name id of the disabled questions. Questions are disabled: * if the question is disabled * if the group of the question is disabled

func (*Survey) GetEnabledQuestions added in v2.1.0

func (s *Survey) GetEnabledQuestions() map[string]bool

GetEnabledQuestions returns a map with the name id of the enabled questions. Questions are enabled: * if the question is enabled and the group of the question is enabled

func (*Survey) GetOptionalQuestions added in v2.1.0

func (s *Survey) GetOptionalQuestions() map[string]bool

GetOptionalQuestions returns a map with the name id of the optional questions.

func (*Survey) GetQuestionsAssignments

func (s *Survey) GetQuestionsAssignments() map[string]string

GetQuestionsAssignments returns a map with the question nameId as key and the group nameId as value. If a question is not assigned to any group, value will be empty.

func (*Survey) GetRequiredAndOptionalQuestions added in v2.1.0

func (s *Survey) GetRequiredAndOptionalQuestions() map[string]bool

GetRequiredAndOptionalQuestions returns a map with the name id of the required and optional questions. The value is true if the question is required, false otherwise.

func (*Survey) GetRequiredQuestions added in v2.1.0

func (s *Survey) GetRequiredQuestions() map[string]bool

GetRequiredQuestions returns a map with the name id of the required questions.

func (*Survey) GroupAnswersByType added in v2.1.0

func (s *Survey) GroupAnswersByType(ans Answers) map[types.QuestionType]Answers

GroupAnswersByType groups the answers by the type of the question.

func (*Survey) RemoveGroup

func (s *Survey) RemoveGroup(groupNameId string) error

RemoveGroup removes a group from the survey given its nameId. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) RemoveQuestion

func (s *Survey) RemoveQuestion(questionNameId string) error

RemoveQuestion removes a question from the survey given its nameId. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) RemoveQuestionFromGroup

func (s *Survey) RemoveQuestionFromGroup(questionNameId, groupNameId string) error

RemoveQuestionFromGroup removes a question from a group in the survey. Args: * questionNameId: the nameId of the question to remove. * groupNameId: the nameId of the group to remove the question from. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) ReviewAnswers

func (s *Survey) ReviewAnswers(ans Answers) (*SurveyResume, error)

ReviewAnswers verifies if the answers provided are valid for this survey. Args: * ans: the answers to check. Returns: * map[string]bool:

  • key: the name id of the missing question
  • value: if the question is required or not
  • error: if an error occurred

func (*Survey) ToJson

func (s *Survey) ToJson() (string, error)

ToJson returns a JSON string representation of the survey.

func (*Survey) ToMap

func (s *Survey) ToMap() (map[string]any, error)

ToMap returns a map representation of the survey.

func (*Survey) TranslateAnswers added in v2.1.0

func (s *Survey) TranslateAnswers(ans Answers, ignoreUnknown bool) (Answers, error)

TranslateAnswers translates the nameIDs of the answers to the values provided in each question (if any, otherwise the nameID is used). Translations: * text type: the value is the same passed in the answer * simple choice type: the value is the value, if any, of the choice with the same nameID as the answer

func (*Survey) UpdateGroup added in v2.1.0

func (s *Survey) UpdateGroup(pg *question.Group) error

UpdateGroup updates an existing group in the survey with the data provided.

func (*Survey) UpdateGroupBytes

func (s *Survey) UpdateGroupBytes(ug []byte) error

UpdateGroupBytes updates a group in the survey given its representation as a byte array It also validates the group and checks if the group is consistent with the survey.

func (*Survey) UpdateGroupJson

func (s *Survey) UpdateGroupJson(ug string) error

UpdateGroupJson updates an existing group in the survey with the data provided as a JSON string. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) UpdateGroupMap

func (s *Survey) UpdateGroupMap(ug map[string]any) error

UpdateGroupMap updates an existing group in the survey with the data provided as a map. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) UpdateGroupQuestions

func (s *Survey) UpdateGroupQuestions(groupNameId string, questionsIds []string) error

UpdateGroupQuestions updates the questions of a group in the survey. Args: * groupNameId: the nameId of the group to update. * questionsIds: the list of questions ids to update the group with. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) UpdateGroupsOrder

func (s *Survey) UpdateGroupsOrder(order []string) error

UpdateGroupsOrder updates the groups order in the survey. It also validates the group and checks if the group is consistent with the survey.

func (*Survey) UpdateQuestion

func (s *Survey) UpdateQuestion(uq *question.Question) error

UpdateQuestion updates an existing question in the survey. It also validates the question and checks if the question is consistent with the survey.

func (*Survey) UpdateQuestionBytes

func (s *Survey) UpdateQuestionBytes(uq []byte) error

UpdateQuestionBytes updates a question in the survey given its representation as a byte array It also validates the question and checks if the question is consistent with the survey.

func (*Survey) UpdateQuestionJson

func (s *Survey) UpdateQuestionJson(uq string) error

UpdateQuestionJson updates an existing question in the survey with the data provided as a JSON string. It also validates the question and checks if the question is consistent with the survey.

func (*Survey) UpdateQuestionMap

func (s *Survey) UpdateQuestionMap(uq map[string]any) error

UpdateQuestionMap updates an existing question in the survey with the data provided as a map. It also validates the question and checks if the question is consistent with the survey.

func (*Survey) ValidateSurvey

func (s *Survey) ValidateSurvey() error

ValidateSurvey validates the survey.

type SurveyResume

type SurveyResume struct {
	TotalsResume `json:",inline" bson:",inline"`

	//----- Others Totals -----//
	// ExternalSurveyIds map of external survey ids. Key: GroupNameId, Value: ExternalSurveyId
	ExternalSurveyIds map[string]string `json:"externalSurveyIds,omitempty" bson:"externalSurveyIds,omitempty"`

	//----- Groups -----//
	// GroupsResume map of groups resume. Key: GroupNameId, Value: GroupResume
	GroupsResume map[string]*GroupTotalsResume `json:"groupsResume,omitempty" bson:"groupsResume,omitempty"`

	//----- Errors -----//
	// InvalidAnswers list of invalid answers
	InvalidAnswers []*InvalidAnswerError `json:"invalidAnswers,omitempty" bson:"invalidAnswers,omitempty"`
}

SurveyResume contains the resume of a survey based on the answers provided. All values are calculated based on the answers provided and over the visible components of the survey.

type TotalsResume

type TotalsResume struct {
	//----- Questions Totals -----//
	// TotalQuestions number of questions in the group
	TotalQuestions int `json:"totalQuestions,omitempty" bson:"totalQuestions,omitempty"`
	// TotalRequiredQuestions number of required questions in the group
	TotalRequiredQuestions int `json:"totalRequiredQuestions,omitempty" bson:"totalRequiredQuestions,omitempty"`

	//----- Answers Totals  -----//
	// TotalQuestionsAnswered number of answered questions in the group
	TotalQuestionsAnswered int `json:"totalQuestionsAnswered,omitempty" bson:"totalQuestionsAnswered,omitempty"`
	// TotalRequiredQuestionsAnswered number of required questions answered in the group
	TotalRequiredQuestionsAnswered int `json:"totalRequiredQuestionsAnswered,omitempty" bson:"totalRequiredQuestionsAnswered,omitempty"`
	// UnansweredQuestions map of unanswered questions, key is the nameId of the question, value is true if the question is required
	UnansweredQuestions map[string]bool `json:"unansweredQuestions,omitempty" bson:"unansweredQuestions,omitempty"`
}

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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