Add guides (#224)

* Add guides

* Add versioning to guides

* Guides: special variables and nested expressions

* Add guides page to vite build

* Add search and hash hooks, guide tags and headings

* Improve guides list and filtering

* Add 1.19 download link
This commit is contained in:
Misode
2022-05-06 06:37:27 +02:00
committed by GitHub
parent c788277450
commit 6f27465c78
29 changed files with 1769 additions and 386 deletions

View File

@@ -10,5 +10,9 @@
"source.organizeImports": true,
"source.fixAll.eslint": true
}
}
},
"typescript.format.semicolons": "remove",
"editor.insertSpaces": false,
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true
}

1191
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@
"deepslate": "^0.9.0-beta.13",
"deepslate-1.18": "npm:deepslate@^0.9.0-beta.9",
"deepslate-rs": "^0.1.6",
"highlight.js": "^11.5.1",
"howler": "^2.2.3",
"js-yaml": "^3.14.1",
"lz-string": "^1.4.4",
@@ -51,10 +52,11 @@
"@typescript-eslint/parser": "^4.25.0",
"cypress": "^9.2.0",
"eslint": "^7.27.0",
"fast-glob": "^3.2.11",
"preact": "^10.5.13",
"preact-router": "^3.2.1",
"rollup-plugin-copy": "^3.4.0",
"typescript": "^4.1.3",
"vite": "^2.3.7"
"vite": "^2.3.7",
"vite-plugin-static-copy": "^0.5.0"
}
}

View File

@@ -4,11 +4,12 @@ import '../styles/global.css'
import '../styles/nodes.css'
import { Analytics } from './Analytics'
import { Header } from './components'
import { Category, Changelog, Generator, Home, Project, Sounds, Versions } from './pages'
import { Category, Changelog, Generator, Guide, Guides, Home, Project, Sounds, Versions } from './pages'
import { cleanUrl } from './Utils'
export function App() {
const changeRoute = (e: RouterOnChangeArgs) => {
window.dispatchEvent(new CustomEvent('replacestate'))
// Needs a timeout to ensure the title is set correctly
setTimeout(() => Analytics.pageview(cleanUrl(e.url)))
}
@@ -23,6 +24,8 @@ export function App() {
<Changelog path="/changelog" />
<Versions path="/versions" />
<Project path="/project" />
<Guides path="/guides/" />
<Guide path="/guides/:id" />
<Generator default />
</Router>
</>

View File

@@ -1,6 +1,7 @@
import type { DataModel } from '@mcschema/core'
import { Path } from '@mcschema/core'
import { getCurrentUrl, route } from 'preact-router'
import yaml from 'js-yaml'
import { route } from 'preact-router'
import rfdc from 'rfdc'
import config from '../config.json'
@@ -8,7 +9,7 @@ export function isPromise(obj: any): obj is Promise<any> {
return typeof (obj as any)?.then === 'function'
}
export function isObject(obj: any) {
export function isObject(obj: any): obj is Record<string, any> {
return typeof obj === 'object' && obj !== null
}
@@ -68,29 +69,79 @@ export function getGenerator(url: string) {
return config.generators.find(g => g.url === trimmedUrl)
}
export function getSearchParams(url: string) {
const searchIndex = url.indexOf('?')
if (searchIndex >= 0) {
url = url.slice(searchIndex + 1)
return new Map(url.split('&').map<[string, string]>(param => {
const index = param.indexOf('=')
if (index === -1) return [param, 'true']
return [decodeURIComponent(param.slice(0, index)), decodeURIComponent(param.slice(index + 1))]
}))
}
return new Map<string, string>()
export function changeUrl({ path, search, hash, replace }: { path?: string, search?: string, hash?: string, replace?: boolean }) {
const url = (path !== undefined ? cleanUrl(path) : location.pathname)
+ (search !== undefined ? (search.startsWith('?') || search.length === 0 ? search : '?' + search) : location.search)
+ (hash !== undefined ? (hash.startsWith('#') ? hash : '#' + hash) : location.hash)
route(url, replace)
}
export function setSeachParams(modifications: Record<string, string | undefined>, newPath?: string) {
const url = getCurrentUrl()
const searchParams = getSearchParams(url)
Object.entries(modifications).forEach(([key, value]) => {
if (value === undefined) searchParams.delete(key)
else searchParams.set(key, value)
})
const search = Array.from(searchParams).map(([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value).replaceAll('%2F', '/')}`)
route(`${newPath ? cleanUrl(newPath) : getPath(url)}${search.length === 0 ? '' : `?${search.join('&')}`}`, true)
export function parseFrontMatter(source: string): Record<string, any> {
const data = yaml.load(source.substring(3, source.indexOf('---', 3)))
if (!isObject(data)) return {}
return data
}
export function versionContent(content: string, version: string) {
let cursor = 0
while (true) {
const start = content.indexOf('{#', cursor)
if (start < 0) {
break
}
const end = findMatchingClose(content, start + 2)
const vStart = content.indexOf('#[', start + 1)
let sub = ''
if (vStart >= 0 && vStart < end) {
const vEnd = content.indexOf(']', vStart + 2)
const v = content.substring(vStart + 2, vEnd)
if (v === version) {
sub = content.substring(vEnd + 1, end).trim()
}
} else {
const key = content.substring(start + 2, end)
const versionConfig = config.versions.find(v => v.id === version)
sub = ({
version: versionConfig?.id,
pack_format: versionConfig?.pack_format.toString(),
} as Record<string, string | undefined>)[key] ?? ''
}
content = content.substring(0, start) + sub + content.substring(end + 2)
cursor = start
}
return content
}
function findMatchingClose(source: string, index: number) {
let depth = 0
let iteration = 0
while (iteration++ < 1000) {
const close = source.indexOf('#}', index)
const open = source.indexOf('{#', index)
if (close < 0) {
console.warn('Missing closing bracket')
return source.length
}
if (open < 0) {
if (depth === 0) {
return close
} else {
depth -= 1
index = close + 2
}
} else if (open < close) {
depth += 1
index = open + 2
} else if (depth === 0) {
return close
} else {
depth -= 1
index = close + 2
}
}
console.warn('Exceeded max iterations while finding closing bracket')
return source.length
}
export function stringToColor(str: string): [number, number, number] {

View File

@@ -9,7 +9,7 @@ export function Giscus({ term }: Props) {
const themeSuffix = actualTheme === 'light' ? '-burn' : ''
const themeUrl = (import.meta as any).env.DEV
? `http://localhost:3000/src/styles/giscus${themeSuffix}.css`
: `https://${location.host}/assets/giscus${themeSuffix}.css`
: `${location.protocol}//${location.host}/assets/giscus${themeSuffix}.css`
return <GiscusReact
repo="misode/misode.github.io"

View File

@@ -0,0 +1,25 @@
import { ChangelogTag } from './versions'
interface Props {
title: string,
link: string,
versions: string[],
tags: string[],
activeTags?: string[],
toggleTag?: (tag: string) => unknown,
}
export function GuideCard({ title, link, versions, tags, activeTags, toggleTag }: Props) {
const onToggleTag = (tag: string) => (e: MouseEvent) => {
if (toggleTag) toggleTag(tag)
e.preventDefault()
e.stopImmediatePropagation()
}
return <a class="guide-card" href={link} >
<span class="guide-versions">{versions.join(' • ')}</span>
<h3>{title}</h3>
<div class="guide-tags">
{tags.sort().map(tag => <ChangelogTag label={tag} onClick={onToggleTag(tag)} active={activeTags?.includes(tag)} />)}
</div>
</a>
}

View File

@@ -6,6 +6,7 @@ export * from './ErrorPanel'
export * from './forms'
export * from './generator'
export * from './Giscus'
export * from './GuideCard'
export * from './Header'
export * from './Icons'
export * from './Octicon'

View File

@@ -56,9 +56,9 @@ export function ChangelogList({ changes, defaultOrder }: Props) {
</div>}
<div class="changelog-list">
{sortedChangelogs === undefined
? <span>{locale('loading')}</span>
? <span class="note">{locale('loading')}</span>
: sortedChangelogs.length === 0
? <span>{locale('changelog.no_results')}</span>
? <span class="note">{locale('changelog.no_results')}</span>
: sortedChangelogs.map(change =>
<ChangelogEntry change={change} activeTags={tags} toggleTag={toggleTag} />)}
</div>

View File

@@ -4,7 +4,7 @@ import { hashString } from '../../Utils'
type TagProps = {
label: string,
active?: boolean,
onClick?: () => unknown,
onClick?: (e: MouseEvent) => unknown,
}
export function ChangelogTag({ label, active, onClick }: TagProps) {
const color = label === 'breaking' ? 5 : hashString(label) % 360

View File

@@ -22,7 +22,7 @@ export function VersionList({ versions, link }: Props) {
return <>
<div class="versions-controls">
<TextInput class="btn btn-input version-search" list="sound-list" placeholder={locale('versions.search')}
<TextInput class="btn btn-input version-search" placeholder={locale('versions.search')}
value={search} onChange={setSearch} />
<Checkbox label="Include snapshots" value={snapshots} onChange={setSnapshots} />
</div>

View File

@@ -32,7 +32,7 @@ export function TitleProvider({ children }: { children: ComponentChildren }) {
const changeTitle = useCallback((title: string, versions?: VersionId[]) => {
versions ??= config.versions.map(v => v.id as VersionId)
const titleVersions = versions.slice(versions.length - VERSIONS_IN_TITLE)
const titleVersions = versions.slice(-VERSIONS_IN_TITLE)
document.title = `${title} Minecraft ${titleVersions.join(', ')}`
setTitle(title)
}, [])

View File

@@ -1,22 +1,23 @@
import type { ComponentChildren } from 'preact'
import { createContext } from 'preact'
import { getCurrentUrl } from 'preact-router'
import { useCallback, useContext, useEffect, useState } from 'preact/hooks'
import { Analytics } from '../Analytics'
import { useSearchParam } from '../hooks'
import type { VersionId } from '../services'
import { VersionIds } from '../services'
import { Store } from '../Store'
import { getSearchParams, setSeachParams } from '../Utils'
const VERSION_PARAM = 'version'
interface Version {
version: VersionId,
changeVersion: (version: VersionId, store?: boolean) => unknown,
changeVersion: (version: VersionId, store?: boolean, updateSearch?: boolean) => unknown,
changeTargetVersion: (version: VersionId, replace?: boolean) => unknown,
}
const Version = createContext<Version>({
version: '1.18.2',
changeVersion: () => {},
changeTargetVersion: () => {},
})
export function useVersion() {
@@ -26,28 +27,29 @@ export function useVersion() {
export function VersionProvider({ children }: { children: ComponentChildren }) {
const [version, setVersion] = useState<VersionId>(Store.getVersion())
const searchParams = getSearchParams(getCurrentUrl())
const targetVersion = searchParams.get(VERSION_PARAM)
const [targetVersion, changeTargetVersion] = useSearchParam(VERSION_PARAM)
useEffect(() => {
if (VersionIds.includes(targetVersion as VersionId) && version !== targetVersion) {
setVersion(targetVersion as VersionId)
}
}, [version, targetVersion])
const changeVersion = useCallback((version: VersionId, store = true) => {
if (getSearchParams(getCurrentUrl()).has(VERSION_PARAM)) {
setSeachParams({ version })
const changeVersion = useCallback((newVersion: VersionId, store = true, updateSearch = false) => {
if (updateSearch || targetVersion) {
changeTargetVersion(newVersion, true)
}
if (store) {
Analytics.setVersion(version)
Store.setVersion(version)
Analytics.setVersion(newVersion)
Store.setVersion(newVersion)
}
setVersion(version)
}, [])
setVersion(newVersion)
}, [targetVersion])
const value: Version = {
version,
changeVersion,
changeTargetVersion,
}
return <Version.Provider value={value}>

View File

@@ -1,5 +1,7 @@
export * from './useActiveTimout'
export * from './useCanvas'
export * from './useFocus'
export * from './useHash'
export * from './useMediaQuery'
export * from './useModel'
export * from './useSearchParam'

27
src/app/hooks/useHash.ts Normal file
View File

@@ -0,0 +1,27 @@
import { useCallback, useEffect, useState } from 'preact/hooks'
import { changeUrl } from '../Utils'
export function useHash(): [string, (hash: string) => unknown] {
const [hash, setHash] = useState(window.location.hash)
const onChange = useCallback(() => {
setHash(window.location.hash)
}, [])
useEffect(() => {
window.addEventListener('hashchange', onChange)
window.addEventListener('replacestate', onChange)
return () => {
window.removeEventListener('hashchange', onChange)
window.removeEventListener('replacestate', onChange)
}
}, [])
const changeHash = useCallback((newHash: string) => {
if (newHash !== hash) {
changeUrl({ hash: newHash })
}
}, [hash])
return [hash, changeHash]
}

View File

@@ -0,0 +1,39 @@
import { useCallback, useEffect, useState } from 'preact/hooks'
import { changeUrl } from '../Utils'
const getValue = (search: string, param: string) => new URLSearchParams(search).get(param) ?? undefined
export function useSearchParam(param: string): [string | undefined, (value: string | undefined, replace?: boolean) => unknown] {
const location = window.location
const [value, setValue] = useState<string | undefined>(getValue(location.search, param))
useEffect(() => {
const onChange = () => {
setValue(getValue(location.search, param))
}
window.addEventListener('popstate', onChange)
window.addEventListener('pushstate', onChange)
window.addEventListener('replacestate', onChange)
return () => {
window.removeEventListener('popstate', onChange)
window.removeEventListener('pushstate', onChange)
window.removeEventListener('replacestate', onChange)
}
}, [])
const changeValue = useCallback((newValue: string | undefined, replace?: boolean) => {
if (newValue !== value) {
const params = new URLSearchParams(location.search)
if (newValue === undefined || newValue.length === 0) {
params.delete(param)
} else {
params.set(param, newValue)
}
changeUrl({ search: params.toString().replaceAll('%7C', '|'), replace })
}
}, [value])
return [value, changeValue]
};

View File

@@ -1,23 +1,23 @@
import { DataModel, Path } from '@mcschema/core'
import { getCurrentUrl, route } from 'preact-router'
import { useEffect, useErrorBoundary, useMemo, useState } from 'preact/hooks'
import { useEffect, useErrorBoundary, useMemo, useRef, useState } from 'preact/hooks'
import config from '../../config.json'
import { Analytics } from '../Analytics'
import { Ad, Btn, BtnMenu, ErrorPanel, HasPreview, Octicon, PreviewPanel, SearchList, SourcePanel, TextInput, Tree } from '../components'
import { useLocale, useProject, useTitle, useVersion } from '../contexts'
import { useActiveTimeout, useModel } from '../hooks'
import { useActiveTimeout, useModel, useSearchParam } from '../hooks'
import { getOutput } from '../schema/transformOutput'
import type { BlockStateRegistry, VersionId } from '../services'
import { checkVersion, fetchPreset, getBlockStates, getCollections, getModel, getSnippet, shareSnippet, SHARE_KEY } from '../services'
import { Store } from '../Store'
import { cleanUrl, deepEqual, getGenerator, getSearchParams, message, setSeachParams } from '../Utils'
import { cleanUrl, deepEqual, getGenerator } from '../Utils'
interface Props {
default?: true,
}
export function Generator({}: Props) {
const { locale } = useLocale()
const { version, changeVersion } = useVersion()
const { version, changeVersion, changeTargetVersion } = useVersion()
const { project, file, updateFile, openFile, closeFile } = useProject()
const [error, setError] = useState<Error | string | null>(null)
const [errorBoundary, errorRetry] = useErrorBoundary()
@@ -31,9 +31,12 @@ export function Generator({}: Props) {
return <main><ErrorPanel error={`Cannot find generator "${getCurrentUrl()}"`} /></main>
}
const allowedVersions = config.versions
.filter(v => checkVersion(v.id, gen.minVersion, gen.maxVersion))
.map(v => v.id as VersionId)
const allowedVersions = useMemo(() => {
return config.versions
.filter(v => checkVersion(v.id, gen.minVersion, gen.maxVersion))
.map(v => v.id as VersionId)
.reverse()
}, [gen.minVersion, gen.maxVersion])
useTitle(locale('title.generator', locale(gen.id)), allowedVersions)
@@ -44,14 +47,15 @@ export function Generator({}: Props) {
setError(`This generator is not available in versions above ${gen.maxVersion}`)
}
const searchParams = getSearchParams(getCurrentUrl())
const currentPreset = searchParams.get('preset')
const sharedSnippetId = searchParams.get(SHARE_KEY)
const [currentPreset, setCurrentPreset] = useSearchParam('preset')
const [sharedSnippetId, setSharedSnippetId] = useSearchParam(SHARE_KEY)
const ignoreChange = useRef(false)
useEffect(() => {
if (model && currentPreset) {
loadPreset(currentPreset).then(preset => {
ignoreChange.current = true
model.reset(DataModel.wrapLists(preset), false)
setSeachParams({ version, preset: currentPreset, [SHARE_KEY]: undefined })
setSharedSnippetId(undefined)
})
} else if (model && sharedSnippetId) {
getSnippet(sharedSnippetId).then(s => loadSnippet(model, s))
@@ -107,11 +111,15 @@ export function Generator({}: Props) {
const [dirty, setDirty] = useState(false)
useModel(model, () => {
setSeachParams({ version: undefined, preset: undefined, [SHARE_KEY]: undefined })
if (!ignoreChange.current) {
setCurrentPreset(undefined, true)
setSharedSnippetId(undefined, true)
}
ignoreChange.current = false
Store.setBackup(gen.id, DataModel.unwrapLists(model?.data))
setError(null)
setDirty(true)
}, [gen.id])
}, [gen.id, setCurrentPreset, setSharedSnippetId])
const [fileRename, setFileRename] = useState('')
const [fileSaved, doSave] = useActiveTimeout()
@@ -211,7 +219,9 @@ export function Generator({}: Props) {
const selectPreset = (id: string) => {
Analytics.generatorEvent('load-preset', id)
setSeachParams({ version, preset: id, [SHARE_KEY]: undefined })
setSharedSnippetId(undefined, true)
changeTargetVersion(version, true)
setCurrentPreset(id)
}
const loadPreset = async (id: string) => {
@@ -226,12 +236,13 @@ export function Generator({}: Props) {
}
return preset
} catch (e) {
setError(e instanceof Error ? e : message(e))
setError(`Cannot load preset ${id} in ${version}`)
setCurrentPreset(undefined, true)
}
}
const selectVersion = (version: VersionId) => {
setSeachParams({ [SHARE_KEY]: undefined })
setSharedSnippetId(undefined, true)
changeVersion(version)
}
@@ -244,13 +255,13 @@ export function Generator({}: Props) {
return
}
if (currentPreset) {
setShareUrl(`${location.protocol}//${location.host}/${gen.url}/?version=${version}&preset=${currentPreset}`)
setShareUrl(`${location.origin}/${gen.url}/?version=${version}&preset=${currentPreset}`)
setShareShown(true)
copySharedId()
} else if (model && blockStates) {
const output = getOutput(model, blockStates)
if (deepEqual(output, model.schema.default())) {
setShareUrl(`${location.protocol}//${location.host}/${gen.url}/?version=${version}`)
setShareUrl(`${location.origin}/${gen.url}/?version=${version}`)
setShareShown(true)
} else {
shareSnippet(gen.id, version, output, previewShown)
@@ -345,7 +356,7 @@ export function Generator({}: Props) {
<SearchList searchPlaceholder={locale('search')} noResults={locale('no_presets')} values={presets} onSelect={selectPreset}/>
</BtnMenu>
<BtnMenu icon="tag" label={version} tooltip={locale('switch_version')} data-cy="version-switcher">
{allowedVersions.reverse().map(v =>
{allowedVersions.map(v =>
<Btn label={v} active={v === version} onClick={() => selectVersion(v)} />
)}
</BtnMenu>

147
src/app/pages/Guide.tsx Normal file
View File

@@ -0,0 +1,147 @@
import hljs from 'highlight.js'
import { marked } from 'marked'
import { route } from 'preact-router'
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
import config from '../../config.json'
import { Ad, Btn, BtnMenu, ChangelogTag, Giscus, Octicon } from '../components'
import { useLocale, useTitle, useVersion } from '../contexts'
import { useActiveTimeout, useHash } from '../hooks'
import type { VersionId } from '../services'
import { parseFrontMatter, versionContent } from '../Utils'
const HASH = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M6.368 1.01a.75.75 0 01.623.859L6.57 4.5h3.98l.46-2.868a.75.75 0 011.48.237L12.07 4.5h2.18a.75.75 0 010 1.5h-2.42l-.64 4h2.56a.75.75 0 010 1.5h-2.8l-.46 2.869a.75.75 0 01-1.48-.237l.42-2.632H5.45l-.46 2.869a.75.75 0 01-1.48-.237l.42-2.632H1.75a.75.75 0 010-1.5h2.42l.64-4H2.25a.75.75 0 010-1.5h2.8l.46-2.868a.75.75 0 01.858-.622zM9.67 10l.64-4H6.33l-.64 4h3.98z"></path></svg>'
marked.use({
highlight: (code, lang) => {
if (lang === '') return undefined
return hljs.highlight(code, { language: lang }).value
},
})
interface Props {
path?: string
id?: string
}
export function Guide({ id }: Props) {
const { locale } = useLocale()
const { version, changeVersion } = useVersion()
const { changeTitle } = useTitle()
const [content, setContent] = useState<string | undefined>(undefined)
const frontMatter = useMemo(() => {
if (!content) return undefined
const data = parseFrontMatter(content)
changeTitle(data?.title, data?.versions)
return data
}, [content])
const allowedVersions = useMemo(() => {
const orderedVersions = config.versions.map(v => v.id)
return (frontMatter?.versions as string[])
?.sort((a, b) => orderedVersions.indexOf(b) - orderedVersions.indexOf(a))
}, [frontMatter?.versions])
const guideVersion = useMemo(() => {
if (!allowedVersions) return version
if (allowedVersions.includes(version)) return version
return allowedVersions[0]
}, [version, frontMatter?.versions])
const versionedContent = useMemo(() => {
if (!content) return undefined
const guide = content.substring(content.indexOf('---', 3) + 3)
return versionContent(guide, guideVersion)
}, [guideVersion, content])
const html = useMemo(() => {
if (!versionedContent) return undefined
marked.use({ renderer: {
link(href, title, text) {
if (href === null) return text
const title2 = title ? ` title="${title}"` : ''
const target = href?.match(/^https?:\/\//) ? ' target="_blank"' : ''
return `<a href="${href}"${title2}${target}>${text}</a>`
},
heading(text, level, raw, slugger) {
const id = slugger.slug(raw)
const link = `<span id="guide-${id}" href="?version=${version}#${id}">${HASH}</span>`
return `<h${level}>${link}${text}</h${level}>`
},
}})
return marked(versionedContent, { version: '1.19' } as any)
}, [versionedContent])
const [hash, setHash] = useHash()
const scrollToHeading = useCallback(() => {
if (!html) return
const heading = document.querySelector(`[id=guide-${hash.slice(1)}]`)
if (heading) {
const top = heading.getBoundingClientRect().top + window.scrollY
window.scrollTo({ top: top - 68, behavior: 'smooth' })
}
}, [html, hash])
useEffect(() => {
scrollToHeading()
}, [html === undefined, hash])
const clickGuideContent = useCallback((e: MouseEvent) => {
if (!(e.target instanceof HTMLSpanElement)) return
const targetHash = '#' + e.target.id.replace(/^guide-/, '')
changeVersion(version, false, true)
setHash(targetHash)
if (targetHash === hash) {
scrollToHeading()
}
}, [scrollToHeading, hash, version])
useEffect(() => {
(async () => {
const res = await fetch(`../../guides/${id}.md`)
const text = await res.text()
setContent(text)
})()
}, [id])
const [shareActive, shareSuccess] = useActiveTimeout()
const onShare = useCallback(() => {
const url = `${location.origin}/guides/${id}/?version=${version}`
navigator.clipboard.writeText(url)
shareSuccess()
}, [id, version])
const onClickTag = (tag: string) => {
route(`/guides/?tags=${tag}`)
}
const [largeWidth] = useState(window.innerWidth > 600)
return <main>
<div class="guide">
<div class="navigation">
<a class="btn btn-link" href="/guides/">
{Octicon.arrow_left}
{locale('guides.all')}
</a>
<Btn icon={shareActive ? 'check' : 'link'} label={locale('share')} onClick={onShare} active={shareActive} tooltip={locale(shareActive ? 'copied' : 'copy_share')} class="guide-share" />
{allowedVersions && <BtnMenu icon="tag" label={guideVersion} tooltip={locale('switch_version')}>
{allowedVersions.map((v: string) =>
<Btn label={v} active={v === guideVersion} onClick={() => changeVersion(v as VersionId)} />)}
</BtnMenu>}
</div>
{(frontMatter?.tags && frontMatter.tags.length > 0) && <div class="guide-tags">
{frontMatter.tags.map((tag: string) =>
<ChangelogTag label={tag} active onClick={() => onClickTag(tag)} />
)}
</div>}
{html && <>
<Ad id="guide" type={largeWidth ? 'image' : 'text'} />
<div class="guide-content" dangerouslySetInnerHTML={{ __html: html }} onClick={clickGuideContent}></div>
<Giscus />
</>}
</div>
</main>
}

87
src/app/pages/Guides.tsx Normal file
View File

@@ -0,0 +1,87 @@
import { useMemo, useState } from 'preact/hooks'
import config from '../../config.json'
import { Btn, BtnMenu, ChangelogTag, GuideCard, TextInput } from '../components'
import { useLocale, useTitle, useVersion } from '../contexts'
import { useSearchParam } from '../hooks'
import type { VersionId } from '../services'
interface Guide {
id: string,
title: string,
versions?: string[],
tags?: string[],
}
declare var __GUIDES__: Guide[]
const TAG_KEY = 'tags'
const TAG_SEP = '|'
interface Props {
path?: string
}
export function Guides({}: Props) {
const { locale } = useLocale()
const { version, changeVersion } = useVersion()
useTitle(locale('title.guides'))
const [search, setSearch] = useState('')
const [tags, setTags] = useSearchParam(TAG_KEY)
const activeTags = useMemo(() => tags?.split(TAG_SEP) ?? [], [tags])
const toggleTag = (tag: string) => {
if (activeTags.includes(tag)) {
setTags(activeTags.filter(t => t !== tag).join(TAG_SEP))
} else {
setTags([...activeTags, tag].sort().join(TAG_SEP))
}
}
const [versionFilter, setVersionFiler] = useState(false)
const versionedGuides = useMemo(() => {
if (versionFilter === false) return __GUIDES__
return __GUIDES__.filter(guide => {
return guide.versions?.includes(version)
})
}, [version, versionFilter])
const filteredGuides = useMemo(() => {
const query = search.split(' ').map(q => q.trim().toLowerCase()).filter(q => q.length > 0)
return versionedGuides.filter(guide => {
if (!activeTags.every(tag => guide.tags?.includes(tag))) {
return false
}
const content = guide.tags?.join(' ') + ' ' + guide.title.toLowerCase()
return query.every(q => {
if (q.startsWith('!')) {
return q.length === 1 || !content.includes(q.slice(1))
}
return content.includes(q)
})
})
}, [versionedGuides, search, tags])
return <main>
<div class="guides">
<div class="changelog-query">
<TextInput class="btn btn-input changelog-search" placeholder={locale('guides.search')} value={search} onChange={setSearch} />
<BtnMenu icon="tag" label={versionFilter ? version : locale('any_version')} tooltip={locale('switch_version')}>
<Btn label={locale('any_version')} active={!versionFilter} onClick={() => setVersionFiler(!versionFilter)} />
{config.versions.slice().reverse().map(v =>
<Btn label={v.id} active={versionFilter && v.id === version} onClick={() => {changeVersion(v.id as VersionId); setVersionFiler(true)}} />
)}
</BtnMenu>
</div>
{activeTags.length > 0 && <div class="changelog-tags">
{activeTags.map(tag => <ChangelogTag label={tag} onClick={() => toggleTag(tag)} />)}
</div>}
{versionedGuides.length === 0 ? <>
<span class="note">{locale('guides.no_results.version')}</span>
</> : filteredGuides.length === 0 ? <>
<span class="note">{locale('guides.no_results.query')}</span>
</> : filteredGuides.map(g =>
<GuideCard title={g.title} link={`/guides/${g.id}/`} tags={g.tags ?? []} versions={g.versions ?? []} activeTags={activeTags} toggleTag={toggleTag} />
)}
</div>
</main>
}

View File

@@ -33,6 +33,7 @@ export function Home({}: Props) {
desc="Convert your data packs from 1.16 to 1.17 to 1.18" />
<ToolCard title="Technical Changelog" link="/changelog/" />
<ToolCard title="Minecraft Versions" link="/versions/" />
<ToolCard title="Data Pack Guides" link="/guides/" />
<Giscus />
</div>
</main>

View File

@@ -64,8 +64,8 @@ export function Sounds({}: Props) {
{configs.length > 1 && <Btn icon="play" label={ locale('sounds.play_all')} class="play-all-sounds" onClick={playAll} />}
<div class="spacer"></div>
<Btn icon="download" label={locale('download')} tooltip={locale('sounds.download_function')} class="download-sounds" onClick={downloadFunction} />
<BtnMenu icon="tag" label={version}>
{config.versions.reverse().map(v =>
<BtnMenu icon="tag" label={version} tooltip={locale('switch_version')}>
{config.versions.slice().reverse().map(v =>
<Btn label={v.id} active={v.id === version} onClick={() => changeVersion(v.id as VersionId)} />
)}
</BtnMenu>

View File

@@ -1,10 +1,9 @@
import { getCurrentUrl } from 'preact-router'
import { useEffect, useState } from 'preact/hooks'
import { Ad, ErrorPanel, Octicon, VersionDetail, VersionList } from '../components'
import { useLocale, useTitle } from '../contexts'
import { useSearchParam } from '../hooks'
import type { VersionMeta } from '../services'
import { fetchVersions } from '../services'
import { getSearchParams } from '../Utils'
interface Props {
path?: string,
@@ -21,7 +20,7 @@ export function Versions({}: Props) {
.catch(e => { console.error(e); setError(e) })
}, [])
const selectedId = getSearchParams(getCurrentUrl()).get('id')
const [selectedId] = useSearchParam('id')
const selected = versions.find(v => v.id === selectedId)
useTitle(selected ? selected.name : 'Versions Explorer', selected ? [] : undefined)
@@ -34,7 +33,7 @@ export function Versions({}: Props) {
{error && <ErrorPanel error={error} onDismiss={() => setError(null)} />}
<div class="versions">
{selectedId ? <>
<div class="version-navigation">
<div class="navigation">
<a class="btn btn-link" href="/versions/">
{Octicon.three_bars}
{locale('versions.all')}

View File

@@ -1,6 +1,8 @@
export * from './Category'
export * from './Changelog'
export * from './Generator'
export * from './Guide'
export * from './Guides'
export * from './Home'
export * from './Project'
export * from './Sounds'

View File

@@ -86,7 +86,7 @@ export async function fetchPreset(versionId: VersionId, registry: string, id: st
const type = ['blockstates', 'models'].includes(registry) ? 'assets' : 'data'
const url = `${mcmeta(version, type)}/${type}/minecraft/${registry}/${id}.json`
const res = await fetch(url)
return res.json()
return await res.json()
} catch (e) {
throw new Error(`Error occurred while fetching ${registry} preset ${id}: ${message(e)}`)
}

View File

@@ -53,27 +53,33 @@
"versions": [
{
"id": "1.15",
"ref": "1.15.2"
"ref": "1.15.2",
"pack_format": 5
},
{
"id": "1.16",
"ref": "1.16.5"
"ref": "1.16.5",
"pack_format": 6
},
{
"id": "1.17",
"ref": "1.17.1"
"ref": "1.17.1",
"pack_format": 7
},
{
"id": "1.18",
"ref": "1.18.1"
"ref": "1.18.1",
"pack_format": 8
},
{
"id": "1.18.2",
"ref": "1.18.2"
"ref": "1.18.2",
"pack_format": 9
},
{
"id": "1.19",
"dynamic": true
"dynamic": true,
"pack_format": 10
}
],
"generators": [

View File

@@ -0,0 +1,161 @@
---
title: Adding custom structures
versions:
- '1.18.2'
- '1.19'
tags:
- worldgen
- structures
---
This guide will showcase how to create a data pack that adds a custom structure to the world. There is also a [data pack download]({#[1.18.2] https://gist.github.com/misode/45559d34627755ecaa52497daea83544/raw/8ece848257e6ce17769ca17eccdf89b5889afbe2/tall-towers-1.18.2.zip #}{#[1.19] https://gist.github.com/misode/45559d34627755ecaa52497daea83544/raw/b7d7c44a132641d308cbdc93ce4cf061759d15c5/tall-towers-1.19.zip #}) of this complete example.
> **Always leave the world and rejoin to apply the new changes!**
## Pack.mcmeta
Like every data pack, we need a `pack.mcmeta`. In this version, the pack format is {#pack_format#}.
```json
{
"pack": {
"pack_format": {#pack_format#},
"description": "A tall tower"
}
}
```
## The structure set
A structure set is where the placement starts. It defines where in the world the structure should be placed, and how rare it is. It takes a weighted list of different structures, allowing structure variants (for example the [vanilla nether](/worldgen/structure-set/?preset=nether_complexes&version={#version#}) has a structure set with both the bastion and fortress).
**`data/example/worldgen/structure_set/tall_towers.json`**
```json
{
"structures": [
{
"structure": "example:tall_tower",
"weight": 1
}
],
"placement": {
"type": "minecraft:random_spread",
"spacing": 5,
"separation": 2,
"salt": 1646207470
}
}
```
Structure sets are made up of two parts:
* `structures`: A weighted list of configured structure features [(see next step)](#the{#[1.18.2] -configured #}-structure).
* `placement`: The structure placement
* `placement.type`: Either `random_spread` or `concentric_rings`. The latter is only used by strongholds in vanilla, so we'll focus on `random_spread`
* `placement.spacing`: Roughly the average distance in chunks between two structures in this set.
* `placement.separation`: The minimum distance in chunks. Needs to be smaller than spacing.
* `placement.salt`: A random number that is combined with the world seed. Always use a different random number for different structures, otherwise they will end up being placed in the same spot!
When using the `random_spread` placement type, it generates structures grid-based. Here's an illustration of the above example with `spacing = 5`, `separation = 2`. There will be one structure attempt in each 5x5 chunk grid, and only at `X` a structure can spawn.
```
.............
..XXX..XXX..X
..XXX..XXX..X
..XXX..XXX..X
.............
.............
..XXX..XXX..X
..XXX..XXX..X
..XXX..XXX..X
```
## The {#[1.18.2] configured structure #}{#[1.19] structure #}
The {#[1.18.2] configured structure (feature) #}{#[1.19] structure #} is the ID you will be able to reference in `/locate`.
**`data/example/worldgen/{#[1.18.2] configured_structure_feature #}{#[1.19] structure #}/tall_tower.json`**
```json
{#[1.18.2]
{
"type": "minecraft:village",
"config": {
"start_pool": "example:tall_tower",
"size": 1
},
"biomes": "#minecraft:has_structure/mineshaft",
"adapt_noise": true,
"spawn_overrides": {}
}
#}{#[1.19]
{
"type": "minecraft:jigsaw",
"biomes": "#minecraft:has_structure/mineshaft",
"step": "surface_structures",
"spawn_overrides": {},
"terrain_adaptation": "beard_thin",
"start_pool": "example:tall_tower",
"size": 1,
"start_height": {
"absolute": 0
},
"project_start_to_heightmap": "WORLD_SURFACE_WG",
"max_distance_from_center": 80,
"use_expansion_hack": false
}
#}
```
Let's go over all the fields.
{#[1.18.2]
* `type`: This is the structure feature type. When making custom structures, you almost always want to set this to `village` or `bastion_remnant`. There is one important difference between the two: using `village` will spawn the structure on the surface, while `bastion_remnant` will always spawn the structure at Y=33.
* `config.start_pool`: This is a reference to the **template pool** [(see next step)](#the-template-pool).
* `config.size`: This is a number between 1 and 7. This is important if your structure uses jigsaw. In this simple example, we'll leave it at 1.
* `biomes`: This controls in which biomes this structure is allowed to generate. You can give it any biome tag, a list of biomes, or a single biome. For easy testing we'll set it to every biome with mineshafts.
* `adapt_noise`: When true, it will add extra terrain below each structure piece.
* `spawn_overrides`: This field allows you to override mob spawning inside the structure bounding boxes. This is outside the scope of this guide, but you could look at the [vanilla monument](/worldgen/structure-feature/?preset=monument&version={#version#}) structure feature as a reference.
#}{#[1.19]
* `type`: This is the structure type. When making custom structures, you almost always want to set this to `jigsaw`.
* `biomes`: This controls in which biomes this structure is allowed to generate. You can give it any biome tag, a list of biomes, or a single biome. For easy testing we'll set it to every biome with mineshafts.
* `step`: The generation step to place the features in. This matches the steps in a biome's `feature` list. Possible values: `raw_generation`, `lakes`, `local_modifications`, `underground_structures`, `surface_structures`, `strongholds`, `underground_ores`, `underground_decoration`, `fluid_springs`, `vegetal_decoration`, and `top_layer_modification`.
* `spawn_overrides`: This field allows you to override mob spawning inside the structure bounding boxes. This is outside the scope of this guide, but you could look at the [vanilla monument](/worldgen/structure/?preset=monument&version={#version#}) structure feature as a reference.
* `start_pool`: This is a reference to the **template pool** [(see next step)](#the-template-pool).
* `size`: This is a number between 1 and 7. This is important if your structure uses jigsaw. In this simple example, we'll leave it at 1.
* `start_height`: A height provider specifying at which height the structure should spawn. The example uses the constant shorthand so it just specifies a vertical anchor.
* `project_start_to_heightmap`: An optional heightmap type. Possible values: `WORLD_SURFACE_WG`, `WORLD_SURFACE`, `OCEAN_FLOOR_WG`, `OCEAN_FLOOR`, `MOTION_BLOCKING`, and `MOTION_BLOCKING_NO_LEAVES`. If `start_height` is not 0, will move the start relative to the heightmap.
* `max_distance_from_center`: Value between 1 and 128. The maximum distance that a jigsaw can branch out.
* `use_expansion_hack`: You should always set this to false. Vanilla villages set this to true to fix an issue with their streets.
#}
## The template pool
The template pool defines how to build up your structure. Since we're not using jigsaw, this is quite straight forward: we want to place a single NBT structure.
**`data/example/worldgen/template_pool/tall_tower.json`**
```json
{
"name": "example:tall_tower",
"fallback": "minecraft:empty",
"elements": [
{
"weight": 1,
"element": {
"element_type": "minecraft:single_pool_element",
"location": "example:stone_tall_tower",
"projection": "rigid",
"processors": "minecraft:empty"
}
}
]
}
```
Again, let's go over the fields:
* `name`: For some reason, the game needs the name of this template pool. Just set this to the ID of the template pool.
* `fallback`: Used in jigsaw structures, but we can simply use `minecraft:empty`.
* `elements`: A weighted list of pool elements to choose from. You can add multiple elements here if your structure has different starting structure files. For example in vanilla a plains village has different town center variants.
* `element_type`: The type of this element. One of `empty_pool_element` (placing nothing), `feature_pool_element` (placing a placed feature), `legacy_single_pool_element`, `list_pool_element`, and `single_pool_element` (placing a structure).
* `location`: The path to the structure NBT file. [(see next step)](#the-structure-nbt).
* `projection`: Either `rigid` or `terrain_matching`. Use the latter if you want the structure to match the terrain, just like village paths do.
* `processors`: If you want to run any processor lists, this is quite complicated so again we'll skip this for now and set it to `minecraft:empty`.
## The structure NBT
Creating the structure NBT file is entirely up to you. In this example I'm going to use a tower structure from [Gamemode 4](https://gm4.co/modules/tower-structures).
**`data/example/structures/stone_tall_tower.nbt`**
(binary NBT file) [Download the structure from this example](https://gist.github.com/misode/45559d34627755ecaa52497daea83544/raw/8b41b3e273210e0455e4bd4fa97b5504b65aff2c/stone_tall_tower.nbt)
## Result
![stone tower close-up](https://user-images.githubusercontent.com/17352009/154780743-c704d23b-9343-4167-8273-acc7a380d037.png)
![a bunch of towers in a forest](https://user-images.githubusercontent.com/17352009/154780794-1585c927-682c-4b26-b1cc-f9132fffc24a.png)

View File

@@ -3,6 +3,7 @@
"add_bottom": "Add to bottom",
"add_top": "Add to top",
"advancement": "Advancement",
"any_version": "Any",
"assets": "Assets",
"block_definition": "Blockstate",
"changelog.search": "Search changes",
@@ -34,6 +35,10 @@
"fields": "Fields",
"generate_new_seed": "Generate new seed",
"github": "GitHub",
"guides.all": "All guides",
"guides.search": "Search guides",
"guides.no_results.version": "No guides for this version",
"guides.no_results.query": "No guides for this query",
"hide_output": "Hide output",
"hide_preview": "Hide preview",
"home": "Home",
@@ -80,6 +85,7 @@
"title.changelog": "Technical Changelog",
"title.generator": "%0% Generator",
"title.generator_category": "%0% Generators",
"title.guides": "Data Pack Guides",
"title.home": "Data Pack Generators",
"title.project": "%0% Project",
"title.sounds": "Sound Explorer",

View File

@@ -808,7 +808,7 @@ main.has-preview {
color: var(--text-1)
}
.home, .category, .project, .versions {
.home, .category, .project, .versions, .guides, .guide {
padding: 16px;
max-width: 960px;
margin: 0 auto;
@@ -882,6 +882,10 @@ hr {
border: none;
}
.note {
color: var(--text-3);
}
.settings {
padding: 20px;
}
@@ -1386,11 +1390,11 @@ hr {
font-size: 1.1rem;
}
.version-navigation {
.navigation {
display: flex;
}
.version-navigation > *:not(:last-child) {
.navigation > *:not(:last-child) {
margin-right: 8px;
}
@@ -1496,6 +1500,168 @@ hr {
margin-top: 16px;
}
.guide-card {
display: block;
text-decoration: none;
color: var(--text-2);
background-color: var(--background-2);
padding: 12px;
border-radius: 6px;
}
.guide-versions {
color: var(--text-3);
float: right;
}
.guide {
padding-left: 32px;
padding-right: 32px;
}
.guide .navigation > :first-child {
margin-right: auto;
}
.guide-share.active {
fill: var(--accent-success);
color: var(--text-2);
}
.guide-tags {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
}
.guide .ad {
margin: 12px 0 8px;
}
.guide .ad[data-ea-type="image"] {
float: right;
margin-left: 12px;
}
.guide-content {
color: var(--text-2);
margin-top: 12px;
line-height: 1.5;
word-wrap: break-word;
}
.guide-content p {
margin-top: 0;
margin-bottom: 10px;
}
.guide-content h1,
.guide-content h2,
.guide-content h3,
.guide-content h4,
.guide-content h5,
.guide-content h6 {
margin: 1.2em 0 0.4em;
position: relative;
}
.guide-content h1 > [id],
.guide-content h2 > [id],
.guide-content h3 > [id],
.guide-content h4 > [id],
.guide-content h5 > [id],
.guide-content h6 > [id] {
fill: var(--text-3);
opacity: 0;
transition: opacity 0.2s;
float: left;
padding-right: 4px;
margin-left: -20px;
cursor: pointer;
}
.guide-content h1 > [id] *,
.guide-content h2 > [id] *,
.guide-content h3 > [id] *,
.guide-content h4 > [id] *,
.guide-content h5 > [id] *,
.guide-content h6 > [id] * {
pointer-events: none;
}
.guide-content h1:hover > [id],
.guide-content h2:hover > [id],
.guide-content h3:hover > [id],
.guide-content h4:hover > [id],
.guide-content h5:hover > [id],
.guide-content h6:hover > [id] {
opacity: 1;
}
.guide-content blockquote {
border-left: 4px solid var(--background-6);
color: var(--text-3);
padding-left: 0.7em;
margin: 0.7em 0;
}
.guide-content blockquote > p {
padding: 0.3em 0;
}
.guide-content a {
text-decoration: underline;
color: var(--accent-primary);
}
.guide-content ul, .guide-content ol {
padding-left: 1.6em;
margin: 0.2em 0 0.5em;
}
.guide-content li + li {
margin-top: 0.25em;
}
.guide-content img {
border-radius: 0.2em;
max-width: 100%;
}
.guide-content code {
display: inline-block;
padding: 0.1em 0.4em;
margin: 0;
background-color: var(--background-2);
border-radius: 6px;
overflow-wrap: break-word;
word-break: break-all;
font-size: 85%;
}
.guide-content pre > code {
display: block;
padding: 0.8em;
margin: 0.2em 0 0.5em;
overflow-x: auto;
}
.guide-content code .hljs-attr {
color: var(--editor-variable);
}
.guide-content code .hljs-string {
color: var(--editor-string);
}
.guide-content code .hljs-number {
color: var(--editor-number);
}
.guide-content code .hljs-keyword {
color: var(--editor-constant);
}
@media screen and (max-width: 720px) {
.sound-search-group {
margin-bottom: 8px;
@@ -1623,4 +1789,8 @@ hr {
.version-metadata-hide {
display: none;
}
.guide-versions {
display: none;
}
}

View File

@@ -1,12 +1,31 @@
import preact from '@preact/preset-vite'
import alias from '@rollup/plugin-alias'
import html from '@rollup/plugin-html'
import glob from 'fast-glob'
import fs from 'fs'
import yaml from 'js-yaml'
import { env } from 'process'
import copy from 'rollup-plugin-copy'
import { defineConfig } from 'vite'
import { viteStaticCopy } from 'vite-plugin-static-copy'
import config from './src/config.json'
import English from './src/locales/en.json'
const guides = glob.sync('src/guides/**/*.md').flatMap(g => {
const content = fs.readFileSync(g).toString('utf-8')
if (!content.startsWith('---')) return []
try {
const frontMatter = yaml.load(content.substring(3, content.indexOf('---', 3)))
if (typeof frontMatter !== 'object') return []
return [{
id: g.replace('src/guides/', '').replace('.md', ''),
...frontMatter,
}]
} catch (e) {
return []
}
})
export default defineConfig({
build: {
sourcemap: true,
@@ -23,31 +42,29 @@ export default defineConfig({
html({
fileName: '404.html',
title: '404',
template: template,
template,
}),
...['sounds', 'changelog', 'versions'].map(id => html({
...['sounds', 'changelog', 'versions', 'guides'].map(id => html({
fileName: `${id}/index.html`,
title: getTitle({ id: `title.${id}`, page: true }),
template: template,
template,
})),
...['worldgen', 'assets'].map(id => html({
fileName: `${id}/index.html`,
title: getTitle({ id, category: true }),
template: template,
template,
})),
...config.generators.map(m => html({
fileName: `${m.url}/index.html`,
title: getTitle(m),
template: template,
template,
})),
copy({
targets: [
{ src: 'src/sitemap.txt', dest: 'dist' },
{ src: 'src/sitemap.txt', dest: 'dist', rename: 'sitemap2.txt' },
{ src: 'src/styles/giscus.css', dest: 'dist/assets' },
{ src: 'src/styles/giscus-burn.css', dest: 'dist/assets' },
],
hook: 'writeBundle',
...guides.map(g => {
return html({
fileName: `guides/${g.id}/index.html`,
title: `${g.title} Minecraft${g.versions ? ` ${g.versions.join(' ')}` : ''}`,
template,
})
}),
],
},
@@ -57,8 +74,19 @@ export default defineConfig({
},
define: {
__LATEST_VERSION__: env.latest_version,
__GUIDES__: guides,
},
plugins: [preact()],
plugins: [
preact(),
viteStaticCopy({
targets: [
{ src: 'src/sitemap.txt', dest: '' },
{ src: 'src/styles/giscus.css', dest: 'assets' },
{ src: 'src/styles/giscus-burn.css', dest: 'assets' },
{ src: 'src/guides/*', dest: 'guides' },
],
}),
],
})
function getTitle(m) {