~shulhan/asciidoctor-go

836385a4ad7bc9a914c9a8544901518f0a64f2ed — Shulhan 3 months ago 64bd814
all: support document attribute "leveloffset"

The ":leveloffset:" on document attribute allow increment
or decrement the heading level on included files.

Reference: https://docs.asciidoctor.org/asciidoc/latest/directives/include-with-leveloffset/
M README.md => README.md +2 -1
@@ 126,6 126,8 @@ Supported document attribute references,
* `idseparator`
* `lastname(_x)`
* `last-update-label`
* [`leveloffset`](https://docs.asciidoctor.org/asciidoc/latest/directives/include-with-leveloffset/).
Only on document attributes, not on include directive.
* `middlename(_x)`
* `nofooter`
* `noheader`


@@ 216,7 218,6 @@ List of features which may be implemented,
* Cross References
  * Inter-document Cross References
* Include Directive
  * Partitioning large documents and using leveloffset
  * AsciiDoc vs non-AsciiDoc files
  * Normalize Block Indentation
  * Include a File Multiple Times in the Same Document

M document_attribute.go => document_attribute.go +26 -5
@@ 3,7 3,11 @@

package asciidoctor

import "strings"
import (
	"fmt"
	"strconv"
	"strings"
)

// List of document attribute.
const (


@@ 22,6 26,7 @@ const (
	docAttrLastName        = `lastname`
	docAttrLastUpdateLabel = `last-update-label`
	docAttrLastUpdateValue = `last-update-value`
	docAttrLevelOffset     = `leveloffset`
	docAttrMiddleName      = `middlename`
	docAttrNoFooter        = `nofooter`
	docAttrNoHeader        = `noheader`


@@ 57,7 62,8 @@ const (
// DocumentAttribute contains the mapping of global attribute keys in the
// headers with its value.
type DocumentAttribute struct {
	Entry map[string]string
	Entry       map[string]string
	LevelOffset int
}

func newDocumentAttribute() DocumentAttribute {


@@ 74,18 80,33 @@ func newDocumentAttribute() DocumentAttribute {
	}
}

func (docAttr *DocumentAttribute) apply(key, val string) {
func (docAttr *DocumentAttribute) apply(key, val string) (err error) {
	if key[0] == '!' {
		key = strings.TrimSpace(key[1:])
		delete(docAttr.Entry, key)
		return
		return nil
	}
	var n = len(key)
	if key[n-1] == '!' {
		key = strings.TrimSpace(key[:n-1])
		delete(docAttr.Entry, key)
		return
		return nil
	}

	if key == docAttrLevelOffset {
		var offset int64
		offset, err = strconv.ParseInt(val, 10, 32)
		if err != nil {
			return fmt.Errorf(`DocumentAttribute: %s: invalid value %q`, key, val)
		}
		if val[0] == '+' || val[0] == '-' {
			docAttr.LevelOffset += int(offset)
			goto valid
		}
		docAttr.LevelOffset = int(offset)
	}

valid:
	docAttr.Entry[key] = val
	return nil
}

A document_attribute_test.go => document_attribute_test.go +100 -0
@@ 0,0 1,100 @@
// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info>
// SPDX-License-Identifier: GPL-3.0-or-later

package asciidoctor

import (
	"testing"

	"git.sr.ht/~shulhan/pakakeh.go/lib/test"
)

func TestDocumentAttributeApply(t *testing.T) {
	type testCase struct {
		desc     string
		key      string
		val      string
		expError string
		exp      DocumentAttribute
	}

	var docAttr = DocumentAttribute{
		Entry: map[string]string{
			`key1`: ``,
			`key2`: ``,
		},
	}

	var listCase = []testCase{{
		key: `key3`,
		exp: docAttr,
	}, {
		desc: `prefix negation`,
		key:  `!key1`,
		exp: DocumentAttribute{
			Entry: map[string]string{
				`key2`: ``,
				`key3`: ``,
			},
		},
	}, {
		desc: `suffix negation`,
		key:  `key2!`,
		exp: DocumentAttribute{
			Entry: map[string]string{
				`key3`: ``,
			},
		},
	}, {
		desc: `leveloffset +`,
		key:  docAttrLevelOffset,
		val:  `+2`,
		exp: DocumentAttribute{
			Entry: map[string]string{
				`key3`:        ``,
				`leveloffset`: `+2`,
			},
			LevelOffset: 2,
		},
	}, {
		desc: `leveloffset -`,
		key:  docAttrLevelOffset,
		val:  `-2`,
		exp: DocumentAttribute{
			Entry: map[string]string{
				`key3`:        ``,
				`leveloffset`: `-2`,
			},
			LevelOffset: 0,
		},
	}, {
		desc: `leveloffset`,
		key:  docAttrLevelOffset,
		val:  `1`,
		exp: DocumentAttribute{
			Entry: map[string]string{
				`key3`:        ``,
				`leveloffset`: `1`,
			},
			LevelOffset: 1,
		},
	}, {
		desc:     `leveloffset: invalid`,
		key:      docAttrLevelOffset,
		val:      `*1`,
		expError: `DocumentAttribute: leveloffset: invalid value "*1"`,
	}}

	var (
		tc  testCase
		err error
	)
	for _, tc = range listCase {
		err = docAttr.apply(tc.key, tc.val)
		if err != nil {
			test.Assert(t, `apply: `+tc.desc, tc.expError, err.Error())
			continue
		}
		test.Assert(t, `apply: `+tc.desc, tc.exp, docAttr)
	}
}

M document_parser.go => document_parser.go +255 -10
@@ 141,19 141,20 @@ func (docp *documentParser) hasPreamble() bool {

		notEmtpy int
		line     []byte
		kind     int
	)
	for ; start < len(docp.lines); start++ {
		line = docp.lines[start]
		if len(line) == 0 {
			continue
		}
		kind, _, _ = whatKindOfLine(line)
		if kind == elKindSectionL1 || kind == elKindSectionL2 ||
			kind == elKindSectionL3 || kind == elKindSectionL4 ||
			kind == elKindSectionL5 ||
			kind == lineKindID ||
			kind == lineKindIDShort {
		_, _ = docp.whatKindOfLine(line)
		if docp.kind == elKindSectionL1 ||
			docp.kind == elKindSectionL2 ||
			docp.kind == elKindSectionL3 ||
			docp.kind == elKindSectionL4 ||
			docp.kind == elKindSectionL5 ||
			docp.kind == lineKindID ||
			docp.kind == lineKindIDShort {
			return notEmtpy > 0
		}
		notEmtpy++


@@ 202,7 203,7 @@ func (docp *documentParser) line(logp string) (spaces, line []byte, ok bool) {
	}
	docp.lineNum++

	docp.kind, spaces, line = whatKindOfLine(line)
	spaces, line = docp.whatKindOfLine(line)
	return spaces, line, true
}



@@ 397,7 398,7 @@ func (docp *documentParser) parseBlock(parent *element, term int) {
					}
					el.Attrs[key] = value
				} else {
					docp.doc.Attributes.apply(key, value)
					_ = docp.doc.Attributes.apply(key, value)
					parent.addChild(&element{
						kind:  docp.kind,
						key:   key,


@@ 749,7 750,7 @@ func (docp *documentParser) parseHeader() {
			var key, value string
			key, value, ok = docp.parseAttribute(line, false)
			if ok {
				docp.doc.Attributes.apply(key, value)
				_ = docp.doc.Attributes.apply(key, value)
			}
			line = nil
			continue


@@ 1599,3 1600,247 @@ func (docp *documentParser) skipCommentAndEmptyLine() (line []byte, ok bool) {
	}
	return line, true
}

// whatKindOfLine return the kind of line.
// It will return lineKindText if the line does not match with known syntax.
func (docp *documentParser) whatKindOfLine(line []byte) (spaces, got []byte) {
	docp.kind = lineKindText

	line = bytes.TrimRight(line, " \f\n\r\t\v")

	// All of the comparison MUST be in order.

	if len(line) == 0 {
		docp.kind = lineKindEmpty
		return nil, line
	}
	if bytes.HasPrefix(line, []byte(`////`)) {
		// Check for comment block first, since we use HasPrefix to
		// check for single line comment.
		docp.kind = lineKindBlockComment
		return spaces, line
	}
	if bytes.HasPrefix(line, []byte(`//`)) {
		// Use HasPrefix to allow single line comment without space,
		// for example "//comment".
		docp.kind = lineKindComment
		return spaces, line
	}

	var strline = string(line)

	switch strline {
	case `'''`, `---`, `- - -`, `***`, `* * *`:
		docp.kind = lineKindHorizontalRule
		return spaces, line
	case `<<<`:
		docp.kind = lineKindPageBreak
		return spaces, line
	case `--`:
		docp.kind = elKindBlockOpen
		return spaces, line
	case `____`:
		docp.kind = elKindBlockExcerpts
		return spaces, line
	case `....`:
		docp.kind = elKindBlockLiteral
		return nil, line
	case `++++`:
		docp.kind = elKindBlockPassthrough
		return spaces, line
	case `****`:
		docp.kind = elKindBlockSidebar
		return nil, line
	case `====`:
		docp.kind = elKindBlockExample
		return spaces, line
	case `[listing]`:
		docp.kind = elKindBlockListingNamed
		return nil, line
	case `[literal]`:
		docp.kind = elKindBlockLiteralNamed
		return nil, line
	case `toc::[]`:
		docp.kind = elKindMacroTOC
		return spaces, line
	}

	if bytes.HasPrefix(line, []byte(`|===`)) {
		docp.kind = elKindTable
		return nil, line
	}
	if bytes.HasPrefix(line, []byte(`image::`)) {
		docp.kind = elKindBlockImage
		return spaces, line
	}
	if bytes.HasPrefix(line, []byte(`include::`)) {
		docp.kind = lineKindInclude
		return nil, line
	}
	if bytes.HasPrefix(line, []byte(`video::`)) {
		docp.kind = elKindBlockVideo
		return nil, line
	}
	if bytes.HasPrefix(line, []byte(`audio::`)) {
		docp.kind = elKindBlockAudio
		return nil, line
	}
	if isAdmonition(line) {
		docp.kind = lineKindAdmonition
		return nil, line
	}

	var (
		x        int
		r        byte
		hasSpace bool
	)
	for x, r = range line {
		if r == ' ' || r == '\t' {
			hasSpace = true
			continue
		}
		break
	}
	if hasSpace {
		spaces = line[:x]
		line = line[x:]

		// A line indented with space only allowed on list item,
		// otherwise it would be set as literal paragraph.

		if isLineDescriptionItem(line) {
			docp.kind = elKindListDescriptionItem
			return spaces, line
		}

		if line[0] != '*' && line[0] != '-' && line[0] != '.' {
			docp.kind = elKindLiteralParagraph
			return spaces, line
		}
	}

	switch line[0] {
	case ':':
		docp.kind = lineKindAttribute
	case '[':
		var (
			newline = bytes.TrimRight(line, " \t")
			l       = len(newline)
		)

		if newline[l-1] != ']' {
			return nil, line
		}
		if l >= 5 {
			// [[x]]
			if newline[1] == '[' && newline[l-2] == ']' {
				docp.kind = lineKindID
				return nil, line
			}
		}
		if l >= 4 {
			// [#x]
			if line[1] == '#' {
				docp.kind = lineKindIDShort
				return nil, line
			}
			// [.x]
			if line[1] == '.' {
				docp.kind = lineKindStyleClass
				return nil, line
			}
		}
		docp.kind = lineKindAttributeElement
		return spaces, line
	case '=', '#':
		var subs = bytes.Fields(line)

		switch string(subs[0]) {
		case `=`, `#`:
			docp.kind = elKindSectionL0
		case `==`, `##`:
			docp.kind = elKindSectionL1
		case `===`, `###`:
			docp.kind = elKindSectionL2
		case `====`, `####`:
			docp.kind = elKindSectionL3
		case `=====`, `#####`:
			docp.kind = elKindSectionL4
		case `======`, `######`:
			docp.kind = elKindSectionL5
		default:
			return spaces, line
		}
		docp.kind += docp.doc.Attributes.LevelOffset
		if docp.kind < elKindSectionL0 || docp.kind > elKindSectionL5 {
			docp.kind = elKindText
		}
		return spaces, line

	case '.':
		switch {
		case len(line) <= 1:
			docp.kind = lineKindText
		case ascii.IsAlnum(line[1]):
			docp.kind = lineKindBlockTitle
		default:
			x = 0
			for ; x < len(line); x++ {
				if line[x] == '.' {
					continue
				}
				if line[x] == ' ' || line[x] == '\t' {
					docp.kind = elKindListOrderedItem
					return spaces, line
				}
			}
		}
	case '*', '-':
		if len(line) <= 1 {
			return spaces, line
		}

		var (
			listItemChar = line[0]
			count        = 0
		)
		x = 0
		for ; x < len(line); x++ {
			if line[x] == listItemChar {
				count++
				continue
			}
			if line[x] == ' ' || line[x] == '\t' {
				docp.kind = elKindListUnorderedItem
				return spaces, line
			}
			// Break on the first non-space, so from above
			// condition we have,
			// - item
			// -- item
			// --- item
			// ---- // block listing
			// --unknown // break here
			break
		}
		if listItemChar == '-' && count == 4 && x == len(line) {
			docp.kind = elKindBlockListing
		} else {
			docp.kind = lineKindText
		}
		return spaces, line
	default:
		switch string(line) {
		case `+`:
			docp.kind = lineKindListContinue
		case `----`:
			docp.kind = elKindBlockListing
		default:
			if isLineDescriptionItem(line) {
				docp.kind = elKindListDescriptionItem
			}
		}
	}
	return spaces, line
}

M element.go => element.go +1 -1
@@ 804,7 804,7 @@ func (el *element) setStyleAdmonition(admName string) {
func (el *element) toHTML(doc *Document, w io.Writer) {
	switch el.kind {
	case lineKindAttribute:
		doc.Attributes.apply(el.key, el.value)
		_ = doc.Attributes.apply(el.key, el.value)

	case elKindCrossReference:
		var (

M parser.go => parser.go +0 -213
@@ 654,216 654,3 @@ func parseStyle(styleName string) (styleKind int64) {

	return 0
}

// whatKindOfLine return the kind of line.
// It will return lineKindText if the line does not match with known syntax.
func whatKindOfLine(line []byte) (kind int, spaces, got []byte) {
	kind = lineKindText

	line = bytes.TrimRight(line, " \f\n\r\t\v")

	// All of the comparison MUST be in order.

	if len(line) == 0 {
		return lineKindEmpty, nil, line
	}
	if bytes.HasPrefix(line, []byte(`////`)) {
		// Check for comment block first, since we use HasPrefix to
		// check for single line comment.
		return lineKindBlockComment, spaces, line
	}
	if bytes.HasPrefix(line, []byte(`//`)) {
		// Use HasPrefix to allow single line comment without space,
		// for example "//comment".
		return lineKindComment, spaces, line
	}

	var strline = string(line)

	switch strline {
	case `'''`, `---`, `- - -`, `***`, `* * *`:
		return lineKindHorizontalRule, spaces, line
	case `<<<`:
		return lineKindPageBreak, spaces, line
	case `--`:
		return elKindBlockOpen, spaces, line
	case `____`:
		return elKindBlockExcerpts, spaces, line
	case `....`:
		return elKindBlockLiteral, nil, line
	case `++++`:
		return elKindBlockPassthrough, spaces, line
	case `****`:
		return elKindBlockSidebar, nil, line
	case `====`:
		return elKindBlockExample, spaces, line
	case `[listing]`:
		return elKindBlockListingNamed, nil, line
	case `[literal]`:
		return elKindBlockLiteralNamed, nil, line
	case `toc::[]`:
		return elKindMacroTOC, spaces, line
	}

	if bytes.HasPrefix(line, []byte(`|===`)) {
		return elKindTable, nil, line
	}
	if bytes.HasPrefix(line, []byte(`image::`)) {
		return elKindBlockImage, spaces, line
	}
	if bytes.HasPrefix(line, []byte(`include::`)) {
		return lineKindInclude, nil, line
	}
	if bytes.HasPrefix(line, []byte(`video::`)) {
		return elKindBlockVideo, nil, line
	}
	if bytes.HasPrefix(line, []byte(`audio::`)) {
		return elKindBlockAudio, nil, line
	}
	if isAdmonition(line) {
		return lineKindAdmonition, nil, line
	}

	var (
		x        int
		r        byte
		hasSpace bool
	)
	for x, r = range line {
		if r == ' ' || r == '\t' {
			hasSpace = true
			continue
		}
		break
	}
	if hasSpace {
		spaces = line[:x]
		line = line[x:]

		// A line indented with space only allowed on list item,
		// otherwise it would be set as literal paragraph.

		if isLineDescriptionItem(line) {
			return elKindListDescriptionItem, spaces, line
		}

		if line[0] != '*' && line[0] != '-' && line[0] != '.' {
			return elKindLiteralParagraph, spaces, line
		}
	}

	switch line[0] {
	case ':':
		kind = lineKindAttribute
	case '[':
		var (
			newline = bytes.TrimRight(line, " \t")
			l       = len(newline)
		)

		if newline[l-1] != ']' {
			return lineKindText, nil, line
		}
		if l >= 5 {
			// [[x]]
			if newline[1] == '[' && newline[l-2] == ']' {
				return lineKindID, nil, line
			}
		}
		if l >= 4 {
			// [#x]
			if line[1] == '#' {
				return lineKindIDShort, nil, line
			}
			// [.x]
			if line[1] == '.' {
				return lineKindStyleClass, nil, line
			}
		}
		return lineKindAttributeElement, spaces, line
	case '=':
		var subs = bytes.Fields(line)

		switch string(subs[0]) {
		case `=`:
			kind = elKindSectionL0
		case `==`:
			kind = elKindSectionL1
		case `===`:
			kind = elKindSectionL2
		case `====`:
			kind = elKindSectionL3
		case `=====`:
			kind = elKindSectionL4
		case `======`:
			kind = elKindSectionL5
		}
		return kind, spaces, line

	case '.':
		switch {
		case len(line) <= 1:
			kind = lineKindText
		case ascii.IsAlnum(line[1]):
			kind = lineKindBlockTitle
		default:
			x = 0
			for ; x < len(line); x++ {
				if line[x] == '.' {
					continue
				}
				if line[x] == ' ' || line[x] == '\t' {
					kind = elKindListOrderedItem
					return kind, spaces, line
				}
			}
		}
	case '*', '-':
		if len(line) <= 1 {
			kind = lineKindText
			return kind, spaces, line
		}

		var (
			listItemChar = line[0]
			count        = 0
		)
		x = 0
		for ; x < len(line); x++ {
			if line[x] == listItemChar {
				count++
				continue
			}
			if line[x] == ' ' || line[x] == '\t' {
				kind = elKindListUnorderedItem
				return kind, spaces, line
			}
			// Break on the first non-space, so from above
			// condition we have,
			// - item
			// -- item
			// --- item
			// ---- // block listing
			// --unknown // break here
			break
		}
		if listItemChar == '-' && count == 4 && x == len(line) {
			kind = elKindBlockListing
		} else {
			kind = lineKindText
		}
		return kind, spaces, line
	default:
		switch string(line) {
		case `+`:
			kind = lineKindListContinue
		case `----`:
			kind = elKindBlockListing
		default:
			if isLineDescriptionItem(line) {
				kind = elKindListDescriptionItem
			}
		}
	}
	return kind, spaces, line
}

A testdata/_includes/section.adoc => testdata/_includes/section.adoc +3 -0
@@ 0,0 1,3 @@
= Section 1

This is included with leveloffset +1.

A testdata/leveloffset_test.txt => testdata/leveloffset_test.txt +17 -0
@@ 0,0 1,17 @@
Test the ":leveloffset:" document attribute.

>>> leveloffset
:leveloffset: +1

include::testdata/_includes/section.adoc[]

<<< leveloffset

<div class="sect1">
<h2 id="section_1">Section 1</h2>
<div class="sectionbody">
<div class="paragraph">
<p>This is included with leveloffset +1.</p>
</div>
</div>
</div>